Skip to main content

huddle_core/app/
mod.rs

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/// Lobby-facing view of a known dial peer: persisted address plus
33/// runtime "is the connection currently up?" status.
34#[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
42/// Parse a user-entered dial address into a libp2p `Multiaddr`.
43/// Accepts `ip:port`, `[ipv6]:port`, or a raw multiaddr starting with `/`.
44pub 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
81/// State for a room we've created or joined this session.
82struct ActiveRoom {
83    info: StoredRoom,
84    crypto: Option<RoomCrypto>,
85    /// Argon2id-derived 32-byte key for unwrapping incoming session keys.
86    /// None for unencrypted rooms.
87    passphrase_key: Option<[u8; KEY_LEN]>,
88    /// Fingerprints of members currently known to be in the room.
89    members: HashSet<String>,
90    /// Ephemeral typing indicators: fingerprint → unix expiry. Pruned
91    /// on read; never persisted.
92    typers: HashMap<String, i64>,
93    /// Phase F: we joined via a short-lived code rather than the
94    /// passphrase. We have other members' session keys (delivered via
95    /// the CodeJoinResponse ECDH handshake) so we can decrypt; but
96    /// without the passphrase we can't wrap our own outbound session
97    /// key for other members. Read-only until an owner re-onboards us
98    /// with the full passphrase. Defaults false for passphrase joins.
99    read_only: bool,
100    /// Phase F: owner-issued join codes for this room (owner side
101    /// only). Pairs of (code, expires_at_unix). Single-use; entries
102    /// removed after a successful CodeJoinResponse goes out.
103    issued_codes: Vec<(String, i64)>,
104}
105
106const TYPING_TTL_SECS: i64 = 3;
107
108/// TTL for a discovered room before it's considered stale (re-announcements
109/// happen every 15 seconds; after 45s of silence we drop it).
110const DISCOVERED_TTL_SECS: i64 = 45;
111const ANNOUNCE_INTERVAL_SECS: u64 = 15;
112
113/// Phase G: in-flight SAS verification state, keyed by tx_id. Held in
114/// memory only; survives just long enough for the two-message
115/// handshake + the user pressing Match on both sides.
116struct SasFlow {
117    room_id: String,
118    partner_fingerprint: String,
119    our_secret: x25519_dalek::StaticSecret,
120    /// Set once we know both sides' pubkeys → the derived SAS code.
121    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    /// Encrypted rooms loaded from storage that we haven't rejoined yet
134    /// in this session (their passphrase-derived key isn't in memory).
135    /// Surfaced in the lobby so the user can re-enter with passphrase.
136    restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
137    /// Peer addresses we've dialed in this process; tracks "is the
138    /// connection currently up" for known peers shown in the lobby.
139    connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
140    /// File chunking + cache + downloads.
141    file_manager: Arc<FileManager>,
142    db: Db,
143    /// 32-byte key Megolm session pickles are encrypted under at rest —
144    /// an HKDF subkey of the master key, or all-zero on the
145    /// `--no-master-passphrase` / unencrypted-DB path.
146    session_persist_key: [u8; 32],
147    /// Phase G: active SAS verifications. Keyed by tx_id (the random
148    /// 16-byte salt picked by the initiator + base64'd).
149    sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
150    /// Phase F: ephemeral X25519 secrets the joiner is holding while
151    /// they wait for the owner's `CodeJoinResponse`. Keyed by
152    /// `(room_id, joiner_fp)` so multiple joiners in the same room can
153    /// be in flight concurrently without trampling each other; and so
154    /// the 30s timeout task (see `join_room_with_code`) can clean up
155    /// its own entry by composite key without racing with peers.
156    pending_code_secrets:
157        Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
158    /// Phase C follow-up: tracks "we dialed this multiaddr because of
159    /// an invite link claiming this fingerprint." When the peer
160    /// identifies (and we can derive their real fp), the post-dial arm
161    /// looks the multiaddr up here and compares — if the claimed and
162    /// derived fingerprints don't match, we disconnect and surface
163    /// an `InviteFingerprintMismatch` event.
164    ///
165    /// libp2p's `/p2p/<peer-id>` segment already enforces this at the
166    /// transport level when present (and our invite generator always
167    /// includes it), so this is defense in depth — but it also makes
168    /// the assert explicit so future invite-format changes can't slip
169    /// in a forgeable fingerprint label.
170    pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
171    /// Phase D follow-up: addresses confirmed reachable by AutoNAT v2
172    /// probes. We emit a `NatStatusChanged` whenever this set
173    /// transitions between empty (private / undetected) and
174    /// non-empty (reachable), so the TUI badge doesn't flap on every
175    /// individual probe.
176    nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
177    /// Phase D follow-up: `/p2p-circuit` reservation addresses we've
178    /// established via configured relays. These are populated when
179    /// `RelayReservationEstablished` arrives and feed into the
180    /// `RoomAnnouncement.host_addrs` field so cross-internet peers
181    /// can bootstrap without an invite link.
182    relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
183    /// Phase D follow-up: per-creator-fingerprint last-dial timestamp.
184    /// Throttles the opportunistic dial we issue when an announcement
185    /// arrives carrying `host_addrs` — we re-dial the same announcer
186    /// at most once per `HOST_ADDR_DIAL_BACKOFF_SECS`.
187    host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
188    app_event_tx: broadcast::Sender<AppEvent>,
189}
190
191/// Phase D follow-up: minimum seconds between two opportunistic
192/// `host_addrs` dials to the same announcer fingerprint.
193const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
194
195impl AppHandle {
196    pub async fn start() -> Result<Self> {
197        Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
198    }
199
200    pub async fn start_with_options(
201        mode: NetworkMode,
202        port: u16,
203        master_key: Option<&[u8; 32]>,
204        relays: Vec<Multiaddr>,
205    ) -> Result<Self> {
206        config::ensure_data_dir()?;
207        // Megolm session state is encrypted at rest with an HKDF subkey
208        // of the master key. With no master key (--no-master-passphrase /
209        // tests) it's persisted under the all-zero key, matching the
210        // unencrypted-DB story.
211        let session_persist_key = match master_key {
212            Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
213            None => [0u8; 32],
214        };
215        let db = storage::open_db(&config::db_path(), master_key)?;
216        Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
217    }
218
219    pub async fn start_with_db(db: Db) -> Result<Self> {
220        Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
221    }
222
223    pub async fn start_with_db_and_options(
224        db: Db,
225        mode: NetworkMode,
226        port: u16,
227        session_persist_key: [u8; 32],
228        relays: Vec<Multiaddr>,
229    ) -> Result<Self> {
230        let identity = Self::load_or_create_identity(&db)?;
231        let identity = Arc::new(identity);
232        info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
233
234        let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
235        let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
236        let network =
237            network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
238
239        let active_rooms = Arc::new(Mutex::new(HashMap::new()));
240        let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
241        let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
242        let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
243        let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
244
245        let handle = Self {
246            identity,
247            network,
248            mode,
249            active_rooms,
250            discovered_rooms,
251            restorable_rooms,
252            connected_dial_addrs,
253            file_manager,
254            db,
255            session_persist_key,
256            sas_flows: Arc::new(Mutex::new(HashMap::new())),
257            pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
258            pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
259            nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
260            relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
261            host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
262            app_event_tx,
263        };
264
265        handle.spawn_event_processor(net_event_rx);
266        handle.spawn_announcement_ticker();
267        handle.spawn_discovered_room_pruner();
268        handle.spawn_known_peer_reconnector();
269        handle.restore_rooms_from_db().await;
270
271        Ok(handle)
272    }
273
274    pub fn mode(&self) -> NetworkMode {
275        self.mode
276    }
277
278    pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
279        self.app_event_tx.subscribe()
280    }
281
282    pub fn fingerprint(&self) -> &str {
283        self.identity.fingerprint()
284    }
285
286    pub fn peer_id(&self) -> PeerId {
287        self.identity.peer_id()
288    }
289
290    pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
291        let now = now_unix();
292        let mut by_id: HashMap<String, DiscoveredRoom> = self
293            .discovered_rooms
294            .lock()
295            .unwrap()
296            .clone();
297
298        // Merge in rooms we're currently in — gossipsub doesn't echo our
299        // own announcements back to us, so without this our own hosted
300        // rooms wouldn't appear in the lobby.
301        for room in self.active_rooms.lock().unwrap().values() {
302            let entry = DiscoveredRoom {
303                room_id: room.info.id.clone(),
304                name: room.info.name.clone(),
305                encrypted: room.info.encrypted,
306                member_count: room.members.len() as u32,
307                creator_fingerprint: room.info.creator_fingerprint.clone(),
308                last_seen: now,
309                restorable: false,
310            };
311            by_id
312                .entry(room.info.id.clone())
313                .and_modify(|d| {
314                    d.last_seen = now;
315                    if entry.member_count > d.member_count {
316                        d.member_count = entry.member_count;
317                    }
318                    d.restorable = false;
319                })
320                .or_insert(entry);
321        }
322
323        // Encrypted rooms we have on disk but haven't rejoined this
324        // session. Only surface them when no fresh discovery / active
325        // entry exists for the same room.
326        for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
327            if by_id.contains_key(id) {
328                continue;
329            }
330            by_id.insert(
331                id.clone(),
332                DiscoveredRoom {
333                    room_id: id.clone(),
334                    name: stored.name.clone(),
335                    encrypted: stored.encrypted,
336                    member_count: 0,
337                    creator_fingerprint: stored.creator_fingerprint.clone(),
338                    last_seen: stored.last_active.unwrap_or(stored.created_at),
339                    restorable: true,
340                },
341            );
342        }
343
344        let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
345        v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
346        v
347    }
348
349    pub fn active_room_ids(&self) -> Vec<String> {
350        self.active_rooms.lock().unwrap().keys().cloned().collect()
351    }
352
353    pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
354        self.active_rooms
355            .lock()
356            .unwrap()
357            .get(room_id)
358            .map(|r| r.info.clone())
359    }
360
361    pub fn room_members(&self, room_id: &str) -> Vec<String> {
362        self.active_rooms
363            .lock()
364            .unwrap()
365            .get(room_id)
366            .map(|r| {
367                let mut m: Vec<String> = r.members.iter().cloned().collect();
368                m.sort();
369                m
370            })
371            .unwrap_or_default()
372    }
373
374    pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
375        repo::get_room_messages(&self.db, room_id, limit)
376    }
377
378    pub fn search_room_messages(
379        &self,
380        room_id: &str,
381        query: &str,
382        limit: i64,
383    ) -> Result<Vec<repo::StoredRoomMessage>> {
384        repo::search_room_messages(&self.db, room_id, query, limit)
385    }
386
387    /// Create a new room. Returns its room_id.
388    pub async fn start_room(
389        &self,
390        name: &str,
391        encrypted: bool,
392        passphrase: Option<&str>,
393    ) -> Result<String> {
394        if encrypted && passphrase.is_none() {
395            return Err(HuddleError::Other(
396                "encrypted room requires a passphrase".into(),
397            ));
398        }
399
400        let created_at = now_unix();
401        let creator_fp = self.identity.fingerprint().to_string();
402        let room_id = derive_room_id(&creator_fp, name, created_at);
403
404        let (passphrase_salt, passphrase_key) = if encrypted {
405            let salt = passphrase::random_salt();
406            let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
407            (Some(salt.to_vec()), Some(key))
408        } else {
409            (None, None)
410        };
411
412        let info = StoredRoom {
413            id: room_id.clone(),
414            name: name.to_string(),
415            creator_fingerprint: creator_fp.clone(),
416            encrypted,
417            passphrase_salt: passphrase_salt.clone(),
418            created_at,
419            last_active: Some(created_at),
420        };
421        repo::insert_room(&self.db, &info)?;
422
423        let crypto = if encrypted {
424            Some(RoomCrypto::new_for_room(
425                self.db.clone(),
426                room_id.clone(),
427                creator_fp.clone(),
428                self.session_persist_key,
429            )?)
430        } else {
431            None
432        };
433
434        let mut members = HashSet::new();
435        members.insert(creator_fp.clone());
436
437        // Phase B: the room creator is the first owner. Persisted now so
438        // the very first announcement includes our fingerprint in
439        // `owner_fingerprints`, letting joiners know who's authorized.
440        repo::upsert_room_member(
441            &self.db,
442            &StoredRoomMember {
443                room_id: room_id.clone(),
444                peer_id: String::new(),
445                fingerprint: creator_fp.clone(),
446                last_seen: Some(created_at),
447                verified: true, // we trust ourselves
448                ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
449                role: "owner".into(),
450            },
451        )?;
452
453        self.active_rooms.lock().unwrap().insert(
454            room_id.clone(),
455            ActiveRoom {
456                info: info.clone(),
457                crypto,
458                passphrase_key,
459                members,
460                typers: HashMap::new(),
461                read_only: false,
462                issued_codes: Vec::new(),
463            },
464        );
465
466        self.network.subscribe_room(room_id.clone()).await;
467        self.announce_room_now(&info, 1).await;
468
469        // Broadcast our presence in the room (with our wrapped session key
470        // if encrypted). Use a small delay so the subscription propagates.
471        let app = self.clone();
472        let rid = room_id.clone();
473        tokio::spawn(async move {
474            tokio::time::sleep(Duration::from_millis(500)).await;
475            if let Err(e) = app.broadcast_member_announce(&rid).await {
476                warn!(%e, "broadcast member announce");
477            }
478        });
479
480        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
481            room_id: room_id.clone(),
482        });
483
484        Ok(room_id)
485    }
486
487    /// Join an existing room. The room may come from a live announcement
488    /// (preferred), our restorable set, or the DB directly — whichever has
489    /// the freshest copy. For encrypted rooms `passphrase` is required.
490    pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
491        // Resolve room metadata from the freshest available source.
492        let (name, creator_fingerprint, encrypted, salt_opt) = {
493            if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
494                let salt = self.get_room_salt(room_id);
495                (d.name, d.creator_fingerprint, d.encrypted, salt)
496            } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
497            {
498                (
499                    stored.name,
500                    stored.creator_fingerprint,
501                    stored.encrypted,
502                    stored.passphrase_salt,
503                )
504            } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
505                (
506                    stored.name,
507                    stored.creator_fingerprint,
508                    stored.encrypted,
509                    stored.passphrase_salt,
510                )
511            } else {
512                return Err(HuddleError::Other(format!("room {room_id} not found")));
513            }
514        };
515
516        if encrypted && passphrase.is_none() {
517            return Err(HuddleError::Other(
518                "encrypted room requires a passphrase".into(),
519            ));
520        }
521
522        let passphrase_key = if encrypted {
523            let salt = salt_opt
524                .clone()
525                .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
526            Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
527        } else {
528            None
529        };
530
531        let info = StoredRoom {
532            id: room_id.to_string(),
533            name,
534            creator_fingerprint,
535            encrypted,
536            passphrase_salt: salt_opt.clone(),
537            created_at: now_unix(),
538            last_active: Some(now_unix()),
539        };
540        repo::insert_room(&self.db, &info)?;
541
542        let crypto = if encrypted {
543            // Reuse persisted Megolm sessions on re-join; only mint a fresh
544            // outbound session when nothing is stored for this room yet.
545            let our_fp = self.identity.fingerprint().to_string();
546            let existing = RoomCrypto::load(
547                self.db.clone(),
548                room_id.to_string(),
549                our_fp.clone(),
550                self.session_persist_key,
551            )?;
552            Some(match existing {
553                Some(c) => c,
554                None => RoomCrypto::new_for_room(
555                    self.db.clone(),
556                    room_id.to_string(),
557                    our_fp,
558                    self.session_persist_key,
559                )?,
560            })
561        } else {
562            None
563        };
564
565        let mut members = HashSet::new();
566        members.insert(self.identity.fingerprint().to_string());
567
568        self.active_rooms.lock().unwrap().insert(
569            room_id.to_string(),
570            ActiveRoom {
571                info: info.clone(),
572                crypto,
573                passphrase_key,
574                members,
575                typers: HashMap::new(),
576                read_only: false,
577                issued_codes: Vec::new(),
578            },
579        );
580        // No longer "restorable" now that we've rejoined.
581        self.restorable_rooms.lock().unwrap().remove(room_id);
582
583        self.network.subscribe_room(room_id.to_string()).await;
584
585        let app = self.clone();
586        let rid = room_id.to_string();
587        tokio::spawn(async move {
588            tokio::time::sleep(Duration::from_millis(500)).await;
589            if let Err(e) = app.broadcast_member_announce(&rid).await {
590                warn!(%e, "broadcast member announce");
591            }
592            // Ask existing members for their session keys.
593            let req = RoomMessage::SessionKeyRequest {
594                requester_fingerprint: app.identity.fingerprint().to_string(),
595            };
596            if let Ok(bytes) = encode_wire(&req) {
597                app.network.publish_room_message(rid.clone(), bytes).await;
598            }
599        });
600
601        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
602            room_id: room_id.to_string(),
603        });
604
605        Ok(())
606    }
607
608    /// Walk the rooms table at startup. Non-encrypted rooms are silently
609    /// restored (subscribed + re-announced). Encrypted rooms get added to
610    /// `restorable_rooms` so the lobby surfaces them and the user can
611    /// re-enter via the join flow with passphrase.
612    async fn restore_rooms_from_db(&self) {
613        let rooms = match repo::list_rooms(&self.db) {
614            Ok(v) => v,
615            Err(e) => {
616                warn!(%e, "list rooms on restore");
617                return;
618            }
619        };
620        let our_fp = self.identity.fingerprint().to_string();
621        let count = rooms.len();
622        for info in rooms {
623            if info.encrypted {
624                self.restorable_rooms
625                    .lock()
626                    .unwrap()
627                    .insert(info.id.clone(), info);
628                continue;
629            }
630            let mut members = HashSet::new();
631            members.insert(our_fp.clone());
632            if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
633                for m in stored_members {
634                    members.insert(m.fingerprint);
635                }
636            }
637            let member_count = members.len() as u32;
638            self.active_rooms.lock().unwrap().insert(
639                info.id.clone(),
640                ActiveRoom {
641                    info: info.clone(),
642                    crypto: None,
643                    passphrase_key: None,
644                    members,
645                    typers: HashMap::new(),
646                    read_only: false,
647                    issued_codes: Vec::new(),
648                },
649            );
650            self.network.subscribe_room(info.id.clone()).await;
651            self.announce_room_now(&info, member_count).await;
652            info!(room_id = %info.id, name = %info.name, "restored room");
653        }
654        if count > 0 {
655            debug!(count, "restored rooms from db");
656        }
657    }
658
659    /// Leave a room. Returns `true` when the `MemberLeave` notice was
660    /// handed to the network layer, `false` when it couldn't be encoded
661    /// (peers then only notice via the discovered-room TTL). The local
662    /// leave always succeeds regardless.
663    pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
664        // Broadcast a leave notice before unsubscribing.
665        let leave_msg = RoomMessage::MemberLeave {
666            sender_fingerprint: self.identity.fingerprint().to_string(),
667        };
668        let dispatched = match encode_wire(&leave_msg) {
669            Ok(bytes) => {
670                self.network
671                    .publish_room_message(room_id.to_string(), bytes)
672                    .await;
673                true
674            }
675            Err(e) => {
676                warn!(%e, %room_id, "failed to encode MemberLeave notice");
677                false
678            }
679        };
680
681        self.active_rooms.lock().unwrap().remove(room_id);
682        self.network.unsubscribe_room(room_id.to_string()).await;
683
684        let _ = self.app_event_tx.send(AppEvent::RoomLeft {
685            room_id: room_id.to_string(),
686        });
687        Ok(dispatched)
688    }
689
690    pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
691        let our_fp = self.identity.fingerprint().to_string();
692        let msg = {
693            let mut rooms = self.active_rooms.lock().unwrap();
694            let room = rooms
695                .get_mut(room_id)
696                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
697
698            if room.read_only {
699                return Err(HuddleError::Other(
700                    "this room is read-only — you joined via code without the passphrase. Ask an owner for the passphrase or wait for a key rotation that includes you.".into(),
701                ));
702            }
703
704            if room.info.encrypted {
705                let crypto = room
706                    .crypto
707                    .as_mut()
708                    .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
709                let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
710                RoomMessage::Encrypted {
711                    sender_fingerprint: our_fp.clone(),
712                    session_id,
713                    ciphertext_b64: base64::Engine::encode(
714                        &base64::engine::general_purpose::STANDARD,
715                        &ct_bytes,
716                    ),
717                }
718            } else {
719                RoomMessage::Plain {
720                    sender_fingerprint: our_fp.clone(),
721                    body: body.to_string(),
722                }
723            }
724        };
725
726        let bytes = encode_wire(&msg)?;
727        self.network
728            .publish_room_message(room_id.to_string(), bytes)
729            .await;
730
731        let now = now_unix();
732        let msg_id =
733            repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
734        repo::update_room_last_active(&self.db, room_id, now)?;
735
736        let _ = self.app_event_tx.send(AppEvent::MessageSent {
737            room_id: room_id.to_string(),
738            body: body.to_string(),
739            message_id: msg_id,
740        });
741
742        Ok(())
743    }
744
745    pub async fn shutdown(&self) {
746        self.network.shutdown().await;
747    }
748
749    // -------------------------------------------------------------------
750    // Dial / known peers
751    // -------------------------------------------------------------------
752
753    /// Dial a peer by a user-entered address. Accepts:
754    /// - `1.2.3.4:9000`
755    /// - `[fe80::1]:9000`
756    /// - `/ip4/.../tcp/...[/p2p/<peer>]` (raw multiaddr)
757    pub async fn dial(&self, input: &str) -> Result<()> {
758        let multiaddr = parse_dial_address(input)?;
759        let canonical = multiaddr.to_string();
760        info!(%canonical, "dialing");
761
762        repo::upsert_known_peer(
763            &self.db,
764            &KnownPeer {
765                address: canonical.clone(),
766                label: None,
767                last_connected_at: None,
768                last_attempt_at: Some(now_unix()),
769                created_at: now_unix(),
770                // Fingerprint isn't known until Identify lands after the
771                // dial completes; the connection-success handler upserts
772                // again with the fingerprint and trusted=true.
773                fingerprint: None,
774                trusted: false,
775            },
776        )?;
777
778        let _ = self.app_event_tx.send(AppEvent::Dialing {
779            address: canonical.clone(),
780        });
781        self.network.dial(multiaddr).await;
782        Ok(())
783    }
784
785    /// Phase D follow-up: snapshot of the NAT reachability state.
786    /// Returns the addresses AutoNAT has confirmed as externally
787    /// reachable in this session. The lobby renders an emoji badge
788    /// from this — non-empty ⇒ '🌐 reachable', empty ⇒ '🏠 LAN only'.
789    pub fn nat_reachable_addrs(&self) -> Vec<String> {
790        self.nat_reachable_addrs
791            .lock()
792            .unwrap()
793            .iter()
794            .cloned()
795            .collect()
796    }
797
798    /// Phase D follow-up: addresses suitable for putting on the wire
799    /// so other peers can dial us. Union of:
800    ///   - AutoNAT-confirmed external addresses (direct internet)
801    ///   - active `/p2p-circuit` reservations on configured relays
802    /// Capped at 4 entries to keep room announcements small.
803    /// Relay-circuit addresses are listed first (they're more likely
804    /// to work for NAT'd peers).
805    pub fn dialable_addrs(&self) -> Vec<String> {
806        let mut out: Vec<String> = self
807            .relay_circuit_addrs
808            .lock()
809            .unwrap()
810            .iter()
811            .cloned()
812            .collect();
813        for a in self.nat_reachable_addrs.lock().unwrap().iter() {
814            if !out.contains(a) {
815                out.push(a.clone());
816            }
817        }
818        out.truncate(4);
819        out
820    }
821
822    /// Phase C follow-up: dial a peer whose multiaddr came from an
823    /// invite link claiming `claimed_fp`. Behaves identically to
824    /// `dial`, but additionally stashes `(canonical_addr → claimed_fp)`
825    /// in `pending_invite_dials` so the `PeerIdentified` handler can
826    /// assert the cryptographic fp matches the human-display one in
827    /// the invite. Mismatch ⇒ disconnect + `InviteFingerprintMismatch`
828    /// event.
829    ///
830    /// libp2p's `/p2p/<peer-id>` segment already enforces this at the
831    /// transport level (and our invite generator always includes it),
832    /// so this is defense in depth — but it makes the assert explicit
833    /// rather than relying on a structural side effect.
834    pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
835        let multiaddr = parse_dial_address(address)?;
836        let canonical = multiaddr.to_string();
837        self.pending_invite_dials
838            .lock()
839            .unwrap()
840            .insert(canonical.clone(), claimed_fp.to_string());
841        // Re-use the standard dial path so KnownPeer rows + status
842        // events look identical to a plain dial.
843        self.dial(address).await
844    }
845
846    pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
847        let connected = self.connected_dial_addrs.lock().unwrap().clone();
848        let stored = repo::list_known_peers(&self.db).unwrap_or_default();
849        stored
850            .into_iter()
851            .map(|p| {
852                let connected_peer = connected.get(&p.address).copied();
853                KnownPeerStatus {
854                    address: p.address,
855                    label: p.label,
856                    last_connected_at: p.last_connected_at,
857                    connected_peer_id: connected_peer,
858                }
859            })
860            .collect()
861    }
862
863    pub async fn forget_peer(&self, address: &str) -> Result<()> {
864        repo::forget_known_peer(&self.db, address)?;
865        self.connected_dial_addrs.lock().unwrap().remove(address);
866        Ok(())
867    }
868
869    /// Re-dial a stored address — used by the lobby's "reconnect" action.
870    pub async fn redial(&self, address: &str) -> Result<()> {
871        self.dial(address).await
872    }
873
874    /// Phase A: user pressed Accept on the inbound-dial modal. Promotes
875    /// the peer to the gossipsub mesh. Does NOT mark them trusted —
876    /// that's `trust_inbound`, the explicit "remember and bypass next
877    /// time" path.
878    pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
879        self.network.accept_inbound(peer_id).await;
880        self.connected_dial_addrs
881            .lock()
882            .unwrap()
883            .insert(address.to_string(), peer_id);
884    }
885
886    /// Phase A: user pressed Reject on the inbound-dial modal. Disconnects
887    /// the peer, adds them to the persistent blocklist, and ensures every
888    /// subsequent connection attempt from this fingerprint is auto-
889    /// dropped without re-prompting.
890    pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
891        self.network.reject_inbound(peer_id).await;
892        repo::block_peer(&self.db, fingerprint, now_unix())?;
893        Ok(())
894    }
895
896    /// Phase A: user pressed Trust+Accept — accept the connection AND
897    /// remember the peer so subsequent connections bypass the modal.
898    pub async fn trust_inbound(
899        &self,
900        peer_id: PeerId,
901        fingerprint: &str,
902        address: &str,
903    ) -> Result<()> {
904        self.network.accept_inbound(peer_id).await;
905        self.connected_dial_addrs
906            .lock()
907            .unwrap()
908            .insert(address.to_string(), peer_id);
909        // Persist the row with trusted=true so future inbound from
910        // this fingerprint short-circuits the modal in
911        // `process_network_event`'s InboundDial handler.
912        repo::upsert_known_peer(
913            &self.db,
914            &KnownPeer {
915                address: address.to_string(),
916                label: None,
917                last_connected_at: Some(now_unix()),
918                last_attempt_at: Some(now_unix()),
919                created_at: now_unix(),
920                fingerprint: Some(fingerprint.to_string()),
921                trusted: true,
922            },
923        )?;
924        Ok(())
925    }
926
927    fn spawn_known_peer_reconnector(&self) {
928        let handle = self.clone();
929        tokio::spawn(async move {
930            // Brief delay so our own listeners come up first.
931            tokio::time::sleep(Duration::from_millis(500)).await;
932            let known = repo::list_known_peers(&handle.db).unwrap_or_default();
933            // Reconnect each peer from its own task on a staggered, jittered
934            // delay so a long known-peer list doesn't fire a synchronized
935            // burst of dials (and serialized DB writes) all at once.
936            for (i, peer) in known.into_iter().enumerate() {
937                let handle = handle.clone();
938                tokio::spawn(async move {
939                    // Deterministic per-address jitter de-correlates peers
940                    // without pulling an RNG into scope.
941                    let jitter = (peer.address.len() as u64 * 37) % 200;
942                    tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
943                    if let Err(e) = handle.dial(&peer.address).await {
944                        debug!(%e, addr = %peer.address, "auto-reconnect failed");
945                    }
946                });
947            }
948        });
949    }
950
951    // -------------------------------------------------------------------
952    // Internal helpers
953    // -------------------------------------------------------------------
954
955    fn load_or_create_identity(db: &Db) -> Result<Identity> {
956        if let Some(stored) = repo::load_identity(db)? {
957            let mut bytes = [0u8; 32];
958            bytes.copy_from_slice(&stored.ed25519_secret);
959            Identity::from_secret_bytes(bytes)
960        } else {
961            let id = Identity::generate()?;
962            repo::save_identity(db, &id.secret_bytes(), now_unix())?;
963            Ok(id)
964        }
965    }
966
967    fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
968        self.active_rooms
969            .lock()
970            .unwrap()
971            .get(room_id)
972            .and_then(|r| r.info.passphrase_salt.clone())
973            .or_else(|| {
974                // Try the cached announcement salt
975                ROOM_SALT_CACHE
976                    .lock()
977                    .unwrap()
978                    .get(room_id)
979                    .cloned()
980            })
981    }
982
983    async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
984        let owner_fingerprints =
985            repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
986        let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
987        let host_addrs = self.dialable_addrs();
988        let ann = RoomAnnouncement {
989            room_id: info.id.clone(),
990            name: info.name.clone(),
991            encrypted: info.encrypted,
992            passphrase_salt: info.passphrase_salt.clone(),
993            member_count,
994            creator_fingerprint: info.creator_fingerprint.clone(),
995            announced_at: now_unix(),
996            owner_fingerprints,
997            verified_only,
998            host_addrs,
999        };
1000        self.network.announce_room(ann).await;
1001    }
1002
1003    async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1004        let our_fp = self.identity.fingerprint().to_string();
1005        let wrapped = {
1006            let mut rooms = self.active_rooms.lock().unwrap();
1007            let room = rooms
1008                .get_mut(room_id)
1009                .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1010            if room.info.encrypted {
1011                let crypto = room.crypto.as_mut().unwrap();
1012                let session_key = crypto.our_session_key_b64();
1013                let passphrase_key = room
1014                    .passphrase_key
1015                    .as_ref()
1016                    .ok_or_else(|| HuddleError::Session("missing passphrase key".into()))?;
1017                Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1018            } else {
1019                None
1020            }
1021        };
1022        let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1023        let msg = RoomMessage::MemberAnnounce {
1024            sender_fingerprint: our_fp,
1025            wrapped_session_key: wrapped,
1026            display_name,
1027            sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1028        };
1029        let bytes = encode_wire(&msg)?;
1030        self.network
1031            .publish_room_message(room_id.to_string(), bytes)
1032            .await;
1033        Ok(())
1034    }
1035
1036    fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1037        let handle = self.clone();
1038        tokio::spawn(async move {
1039            while let Some(event) = net_rx.recv().await {
1040                handle.process_network_event(event).await;
1041            }
1042            info!("event processor stopped");
1043        });
1044    }
1045
1046    fn spawn_announcement_ticker(&self) {
1047        let handle = self.clone();
1048        tokio::spawn(async move {
1049            let mut interval =
1050                tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1051            interval.tick().await; // skip the immediate tick
1052            loop {
1053                interval.tick().await;
1054                let snapshot: Vec<(StoredRoom, u32)> = {
1055                    let active = handle.active_rooms.lock().unwrap();
1056                    active
1057                        .values()
1058                        .map(|r| (r.info.clone(), r.members.len() as u32))
1059                        .collect()
1060                };
1061                for (info, member_count) in snapshot {
1062                    handle.announce_room_now(&info, member_count).await;
1063                }
1064            }
1065        });
1066    }
1067
1068    fn spawn_discovered_room_pruner(&self) {
1069        let handle = self.clone();
1070        tokio::spawn(async move {
1071            let mut interval = tokio::time::interval(Duration::from_secs(10));
1072            interval.tick().await;
1073            loop {
1074                interval.tick().await;
1075                let now = now_unix();
1076                let mut to_drop = Vec::new();
1077                {
1078                    let mut map = handle.discovered_rooms.lock().unwrap();
1079                    map.retain(|id, r| {
1080                        if now - r.last_seen > DISCOVERED_TTL_SECS {
1081                            to_drop.push(id.clone());
1082                            false
1083                        } else {
1084                            true
1085                        }
1086                    });
1087                }
1088                for id in to_drop {
1089                    let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1090                }
1091            }
1092        });
1093    }
1094
1095    async fn process_network_event(&self, event: NetworkEvent) {
1096        match event {
1097            NetworkEvent::PeerDiscovered { peer_id } => {
1098                let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1099            }
1100            NetworkEvent::PeerExpired { peer_id } => {
1101                // Drop any tracked dial-connection entry for this peer so
1102                // the lobby's online/offline dots stay accurate. mDNS
1103                // expiry only gives us a PeerId (no fingerprint), so we
1104                // can't touch room membership here — that relies on the
1105                // explicit MemberLeave path and the discovered-room TTL.
1106                self.connected_dial_addrs
1107                    .lock()
1108                    .unwrap()
1109                    .retain(|_addr, pid| *pid != peer_id);
1110                let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1111            }
1112            NetworkEvent::ListeningOn { address } => {
1113                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1114                    address: address.to_string(),
1115                });
1116            }
1117            NetworkEvent::RoomAnnouncementReceived(ann) => {
1118                // Cache the salt for join_room
1119                if let Some(salt) = &ann.passphrase_salt {
1120                    ROOM_SALT_CACHE
1121                        .lock()
1122                        .unwrap()
1123                        .insert(ann.room_id.clone(), salt.clone());
1124                }
1125                // Phase D follow-up: opportunistically dial the
1126                // announcer's first host_addr if we're not already
1127                // connected. Skips self-announcements + rate-limits
1128                // by creator fingerprint so we don't dial-storm.
1129                let our_fp_for_dial = self.identity.fingerprint().to_string();
1130                if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1131                    let now = now_unix();
1132                    let should_dial = {
1133                        let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1134                        match attempts.get(&ann.creator_fingerprint).copied() {
1135                            Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1136                            _ => {
1137                                attempts.insert(ann.creator_fingerprint.clone(), now);
1138                                true
1139                            }
1140                        }
1141                    };
1142                    if should_dial {
1143                        if let Some(first) = ann.host_addrs.first() {
1144                            info!(
1145                                announcer = %ann.creator_fingerprint,
1146                                addr = %first,
1147                                "opportunistic dial via room announcement host_addrs"
1148                            );
1149                            // dial is fire-and-forget; failures land in
1150                            // DialFailed and the user doesn't need to know.
1151                            let _ = self.dial(first).await;
1152                        }
1153                    }
1154                }
1155                let discovered = DiscoveredRoom {
1156                    room_id: ann.room_id.clone(),
1157                    name: ann.name.clone(),
1158                    encrypted: ann.encrypted,
1159                    member_count: ann.member_count,
1160                    creator_fingerprint: ann.creator_fingerprint.clone(),
1161                    last_seen: now_unix(),
1162                    restorable: false,
1163                };
1164                // If we're already in this room, cache the announcement so
1165                // others can still discover it through us, but don't emit
1166                // RoomDiscovered — it isn't "newly discovered" to us, and
1167                // emitting it spuriously re-opens the lobby join prompt.
1168                if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1169                    self.discovered_rooms
1170                        .lock()
1171                        .unwrap()
1172                        .insert(ann.room_id.clone(), discovered);
1173                    return;
1174                }
1175                self.discovered_rooms
1176                    .lock()
1177                    .unwrap()
1178                    .insert(ann.room_id.clone(), discovered.clone());
1179                let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1180            }
1181            NetworkEvent::RoomMessageReceived {
1182                room_id,
1183                payload,
1184                from_peer: _,
1185            } => {
1186                // v0.3.0+: every wire message is a `WireMessage` envelope.
1187                // `Plain` carries an unsigned `RoomMessage`; `Signed` is an
1188                // app-level Ed25519 envelope that we verify before
1189                // unwrapping. A failed verify is logged and dropped — we
1190                // never dispatch unverified-but-claiming-to-be-signed
1191                // messages.
1192                let wire: WireMessage = match serde_json::from_slice(&payload) {
1193                    Ok(w) => w,
1194                    Err(e) => {
1195                        warn!(%e, "bad wire envelope");
1196                        return;
1197                    }
1198                };
1199                let (msg, verified_signer) = match wire {
1200                    WireMessage::Plain(m) => (m, None),
1201                    WireMessage::Signed(env) => {
1202                        let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1203                        match crate::crypto::verify_signed(&env) {
1204                            Ok((m, fp)) => {
1205                                // Defense in depth: if we've persisted
1206                                // a pubkey for this fingerprint in this
1207                                // room before, the envelope's pubkey
1208                                // MUST match it. A different pubkey for
1209                                // the same fingerprint means identity
1210                                // drift — TOFU violation — drop.
1211                                match repo::get_member_ed25519_pubkey(
1212                                    &self.db, &room_id, &fp,
1213                                ) {
1214                                    Ok(Some(known)) if known != claimed_pubkey => {
1215                                        warn!(
1216                                            %fp, %room_id,
1217                                            "pubkey mismatch vs stored; dropping signed message"
1218                                        );
1219                                        return;
1220                                    }
1221                                    _ => {}
1222                                }
1223                                (m, Some(fp))
1224                            }
1225                            Err(e) => {
1226                                warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1227                                return;
1228                            }
1229                        }
1230                    }
1231                };
1232                self.handle_room_message(&room_id, msg, verified_signer).await;
1233            }
1234            NetworkEvent::DialSucceeded { peer_id, address } => {
1235                let addr_s = address.to_string();
1236                self.connected_dial_addrs
1237                    .lock()
1238                    .unwrap()
1239                    .insert(addr_s.clone(), peer_id);
1240                // Fingerprint isn't known yet (Identify hasn't landed);
1241                // the PeerIdentified handler below upserts again to add
1242                // the fingerprint and flip trusted=true once it does.
1243                let _ = repo::upsert_known_peer(
1244                    &self.db,
1245                    &KnownPeer {
1246                        address: addr_s.clone(),
1247                        label: None,
1248                        last_connected_at: Some(now_unix()),
1249                        last_attempt_at: Some(now_unix()),
1250                        created_at: now_unix(),
1251                        fingerprint: None,
1252                        trusted: false,
1253                    },
1254                );
1255                let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1256                    address: addr_s,
1257                    peer_id,
1258                });
1259            }
1260            NetworkEvent::DialFailed { address, error } => {
1261                let addr_s = address.to_string();
1262                let _ = self.app_event_tx.send(AppEvent::DialFailed {
1263                    address: addr_s,
1264                    error,
1265                });
1266            }
1267            NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1268                // For any address we user-dialed for this peer, retroactively
1269                // backfill the fingerprint and flip trusted=true. The
1270                // upsert's COALESCE preserves fingerprint once set and
1271                // its trusted-is-sticky-once-true clause means we don't
1272                // accidentally demote a row that was already trusted.
1273                let matched_addrs: Vec<String> = {
1274                    let map = self.connected_dial_addrs.lock().unwrap();
1275                    map.iter()
1276                        .filter_map(|(addr, pid)| {
1277                            if *pid == peer_id {
1278                                Some(addr.clone())
1279                            } else {
1280                                None
1281                            }
1282                        })
1283                        .collect()
1284                };
1285                // Phase C follow-up: if any of these addresses came
1286                // from an invite, verify the invite's claimed fp
1287                // against what we just derived from the pubkey. A
1288                // mismatch means the invite's fp label disagrees with
1289                // libp2p's /p2p/<peer-id> cryptographic anchor —
1290                // structurally impossible when both fields are
1291                // generated from the same identity, but the explicit
1292                // assert defends against future invite-format
1293                // changes or hand-edited links.
1294                let mismatch = {
1295                    let mut map = self.pending_invite_dials.lock().unwrap();
1296                    let mut found: Option<(String, String)> = None;
1297                    for addr in &matched_addrs {
1298                        if let Some(claimed) = map.remove(addr) {
1299                            if claimed != fingerprint {
1300                                found = Some((addr.clone(), claimed));
1301                                break;
1302                            }
1303                        }
1304                    }
1305                    found
1306                };
1307                if let Some((addr, claimed)) = mismatch {
1308                    warn!(
1309                        %addr, %claimed, actual=%fingerprint,
1310                        "invite fingerprint mismatch — disconnecting"
1311                    );
1312                    self.network.disconnect_peer(peer_id).await;
1313                    let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
1314                        address: addr,
1315                        claimed,
1316                        actual: fingerprint.clone(),
1317                    });
1318                    return;
1319                }
1320                for addr in matched_addrs {
1321                    let _ = repo::upsert_known_peer(
1322                        &self.db,
1323                        &KnownPeer {
1324                            address: addr,
1325                            label: None,
1326                            last_connected_at: Some(now_unix()),
1327                            last_attempt_at: Some(now_unix()),
1328                            created_at: now_unix(),
1329                            fingerprint: Some(fingerprint.clone()),
1330                            trusted: true,
1331                        },
1332                    );
1333                }
1334            }
1335            NetworkEvent::RelayReservationEstablished { address } => {
1336                // Treat the circuit address like any other listen
1337                // address — the TUI's ListeningOn handler dedups + adds
1338                // it to the addresses pane. Also emit a status hint via
1339                // ListeningOn so the lobby's reachability line updates.
1340                info!(addr = %address, "relay reservation established");
1341                self.relay_circuit_addrs
1342                    .lock()
1343                    .unwrap()
1344                    .insert(address.to_string());
1345                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1346                    address: address.to_string(),
1347                });
1348            }
1349            NetworkEvent::NatProbeResult {
1350                tested_addr,
1351                reachable,
1352            } => {
1353                let addr_s = tested_addr.to_string();
1354                let (transitioned, becomes_reachable) = {
1355                    let mut set = self.nat_reachable_addrs.lock().unwrap();
1356                    let was_empty = set.is_empty();
1357                    if reachable {
1358                        set.insert(addr_s.clone());
1359                    } else {
1360                        set.remove(&addr_s);
1361                    }
1362                    let is_empty = set.is_empty();
1363                    (was_empty != is_empty, !is_empty)
1364                };
1365                if transitioned {
1366                    let label = if becomes_reachable {
1367                        "reachable".to_string()
1368                    } else {
1369                        "private".to_string()
1370                    };
1371                    info!(reachable = %becomes_reachable, "NAT reachability changed");
1372                    let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
1373                        label,
1374                        reachable: becomes_reachable,
1375                    });
1376                }
1377            }
1378            NetworkEvent::DcutrUpgrade {
1379                remote_peer,
1380                success,
1381            } => {
1382                if success {
1383                    // Render the peer as the last 8 chars of the
1384                    // PeerId for compactness — full peer id is too long
1385                    // for a status line.
1386                    let s = remote_peer.to_base58();
1387                    let tail: String = s.chars().rev().take(8).collect::<String>()
1388                        .chars()
1389                        .rev()
1390                        .collect();
1391                    let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
1392                        peer_label: tail,
1393                    });
1394                }
1395            }
1396            NetworkEvent::InboundDial {
1397                peer_id,
1398                fingerprint,
1399                address,
1400            } => {
1401                // First: cheap server-side filters before bothering the user.
1402                if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
1403                    info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
1404                    self.network.reject_inbound(peer_id).await;
1405                    return;
1406                }
1407                // Phase E: global verified-only inbound mode. If on,
1408                // reject any unverified fingerprint without prompting.
1409                // SAS-verified (Phase G) and already-trusted (Phase A)
1410                // peers still come through.
1411                let global_verified_only =
1412                    repo::get_setting(&self.db, "verified_only_inbound")
1413                        .ok()
1414                        .flatten()
1415                        .map(|v| v == "1")
1416                        .unwrap_or(false);
1417                if global_verified_only {
1418                    let is_verified =
1419                        repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
1420                            || repo::is_fingerprint_trusted(&self.db, &fingerprint)
1421                                .unwrap_or(false);
1422                    if !is_verified {
1423                        info!(
1424                            %fingerprint,
1425                            "inbound dial auto-rejected: verified-only mode"
1426                        );
1427                        self.network.reject_inbound(peer_id).await;
1428                        return;
1429                    }
1430                }
1431                if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
1432                    info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
1433                    // Persist the address → peer_id mapping just as a
1434                    // user-dial would, so the lobby's online dot lights up.
1435                    self.connected_dial_addrs
1436                        .lock()
1437                        .unwrap()
1438                        .insert(address.to_string(), peer_id);
1439                    let _ = repo::upsert_known_peer(
1440                        &self.db,
1441                        &KnownPeer {
1442                            address: address.to_string(),
1443                            label: None,
1444                            last_connected_at: Some(now_unix()),
1445                            last_attempt_at: Some(now_unix()),
1446                            created_at: now_unix(),
1447                            fingerprint: Some(fingerprint),
1448                            trusted: true,
1449                        },
1450                    );
1451                    self.network.accept_inbound(peer_id).await;
1452                    return;
1453                }
1454                // Unknown peer — surface the modal in the TUI.
1455                let _ = self.app_event_tx.send(AppEvent::InboundDial {
1456                    peer_id,
1457                    fingerprint,
1458                    address: address.to_string(),
1459                });
1460            }
1461        }
1462    }
1463
1464    /// `verified_signer` is `Some(fp)` if this message arrived inside a
1465    /// successfully-verified `WireMessage::Signed` envelope — in which
1466    /// case the inner sender_fingerprint *must* match. `None` for
1467    /// `WireMessage::Plain`. Phase B's `OwnerGrant`/`BanMember` arms
1468    /// require it to be `Some` AND the signer to be a current owner.
1469    async fn handle_room_message(
1470        &self,
1471        room_id: &str,
1472        msg: RoomMessage,
1473        verified_signer: Option<String>,
1474    ) {
1475        let our_fp = self.identity.fingerprint().to_string();
1476        match msg {
1477            RoomMessage::MemberAnnounce {
1478                sender_fingerprint,
1479                wrapped_session_key,
1480                display_name,
1481                sender_ed25519_pubkey,
1482            } => {
1483                if sender_fingerprint == our_fp {
1484                    return;
1485                }
1486                // Drop announcements from banned fingerprints — they
1487                // can't rejoin until an owner unbans them (Phase B).
1488                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
1489                    .unwrap_or(false)
1490                {
1491                    info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
1492                    return;
1493                }
1494                // Phase E per-room enforcement: if this room is
1495                // verified-only and the joiner isn't globally SAS-
1496                // verified, refuse to add them. The lowest-fp owner
1497                // (deterministic across honest peers) also sends a
1498                // signed `JoinRefused` so the joiner gets an explicit
1499                // message instead of a silent hang.
1500                if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
1501                    && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
1502                {
1503                    info!(
1504                        %sender_fingerprint, %room_id,
1505                        "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
1506                    );
1507                    let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
1508                    let lowest_owner = owners.iter().min().cloned();
1509                    if lowest_owner.as_deref() == Some(&our_fp) {
1510                        let msg = RoomMessage::JoinRefused {
1511                            room_id: room_id.to_string(),
1512                            target_fingerprint: sender_fingerprint.clone(),
1513                            reason: "room requires SAS verification — ask an existing member to verify you".into(),
1514                        };
1515                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
1516                            if let Ok(bytes) =
1517                                crate::network::protocol::encode_wire_signed(&env)
1518                            {
1519                                self.network
1520                                    .publish_room_message(room_id.to_string(), bytes)
1521                                    .await;
1522                            }
1523                        }
1524                    }
1525                    return;
1526                }
1527                let need_inbound = {
1528                    let mut rooms = self.active_rooms.lock().unwrap();
1529                    let room = match rooms.get_mut(room_id) {
1530                        Some(r) => r,
1531                        None => return,
1532                    };
1533                    let newly_added = room.members.insert(sender_fingerprint.clone());
1534                    if newly_added {
1535                        let _ = self.app_event_tx.send(AppEvent::MemberJoined {
1536                            room_id: room_id.to_string(),
1537                            fingerprint: sender_fingerprint.clone(),
1538                        });
1539                    }
1540                    // Persist member with optional display name + pubkey.
1541                    // `ed25519_pubkey` is `None` for pre-0.3 peers; the
1542                    // upsert COALESCEs so once we learn it we never lose
1543                    // it on a later announce that drops the field.
1544                    let _ = repo::upsert_room_member(
1545                        &self.db,
1546                        &StoredRoomMember {
1547                            room_id: room_id.to_string(),
1548                            peer_id: String::new(), // unknown at this layer
1549                            fingerprint: sender_fingerprint.clone(),
1550                            last_seen: Some(now_unix()),
1551                            verified: false,
1552                            ed25519_pubkey: sender_ed25519_pubkey.clone(),
1553                            // Role is set on first insert only — the
1554                            // upsert ON CONFLICT clause preserves an
1555                            // existing 'owner' on re-announce. A genuine
1556                            // new fingerprint is a 'member' until an
1557                            // OwnerGrant lands.
1558                            role: "member".into(),
1559                        },
1560                    );
1561                    if let Some(name) = display_name.as_deref() {
1562                        let _ = repo::set_member_display_name(
1563                            &self.db,
1564                            room_id,
1565                            &sender_fingerprint,
1566                            Some(name),
1567                        );
1568                    }
1569                    room.info.encrypted && wrapped_session_key.is_some()
1570                };
1571
1572                if need_inbound {
1573                    let wrapped = wrapped_session_key.unwrap();
1574                    let result = {
1575                        let mut rooms = self.active_rooms.lock().unwrap();
1576                        let room = rooms.get_mut(room_id).unwrap();
1577                        let passphrase_key = match &room.passphrase_key {
1578                            Some(k) => k,
1579                            None => {
1580                                warn!("no passphrase key when receiving session key");
1581                                return;
1582                            }
1583                        };
1584                        match passphrase::unwrap(&wrapped, passphrase_key) {
1585                            Ok(plain) => match String::from_utf8(plain) {
1586                                Ok(key_b64) => {
1587                                    let crypto = room.crypto.as_mut().unwrap();
1588                                    crypto.add_inbound_session(&sender_fingerprint, &key_b64)
1589                                }
1590                                Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
1591                            },
1592                            Err(e) => Err(e),
1593                        }
1594                    };
1595                    if let Err(e) = result {
1596                        error!(%e, "add inbound session failed");
1597                    }
1598                }
1599            }
1600            RoomMessage::SessionKeyRequest {
1601                requester_fingerprint,
1602            } => {
1603                if requester_fingerprint == our_fp {
1604                    return;
1605                }
1606                // Re-announce ourselves to share our session key with the new joiner.
1607                if let Err(e) = self.broadcast_member_announce(room_id).await {
1608                    warn!(%e, "broadcast member announce on request");
1609                }
1610            }
1611            RoomMessage::Encrypted {
1612                sender_fingerprint,
1613                session_id,
1614                ciphertext_b64,
1615            } => {
1616                if sender_fingerprint == our_fp {
1617                    return;
1618                }
1619                let ct_bytes = match base64::Engine::decode(
1620                    &base64::engine::general_purpose::STANDARD,
1621                    &ciphertext_b64,
1622                ) {
1623                    Ok(b) => b,
1624                    Err(e) => {
1625                        warn!(%e, "bad base64 ciphertext");
1626                        return;
1627                    }
1628                };
1629                let plaintext = {
1630                    let mut rooms = self.active_rooms.lock().unwrap();
1631                    let room = match rooms.get_mut(room_id) {
1632                        Some(r) => r,
1633                        None => return,
1634                    };
1635                    let crypto = match room.crypto.as_mut() {
1636                        Some(c) => c,
1637                        None => return,
1638                    };
1639                    crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
1640                };
1641                match plaintext {
1642                    Ok(pt) => {
1643                        let body = String::from_utf8_lossy(&pt).to_string();
1644                        let sent_at = now_unix();
1645                        let _ = repo::insert_room_message(
1646                            &self.db,
1647                            room_id,
1648                            &sender_fingerprint,
1649                            "in",
1650                            &body,
1651                            sent_at,
1652                        );
1653                        let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
1654                        self.maybe_emit_mention(room_id, &body);
1655                        let _ = self.app_event_tx.send(AppEvent::MessageReceived {
1656                            room_id: room_id.to_string(),
1657                            sender_fingerprint,
1658                            body,
1659                            sent_at,
1660                        });
1661                    }
1662                    Err(e) => {
1663                        debug!(%e, "decrypt failed (probably missing session key)");
1664                    }
1665                }
1666            }
1667            RoomMessage::Plain {
1668                sender_fingerprint,
1669                body,
1670            } => {
1671                if sender_fingerprint == our_fp {
1672                    return;
1673                }
1674                let sent_at = now_unix();
1675                let _ = repo::insert_room_message(
1676                    &self.db,
1677                    room_id,
1678                    &sender_fingerprint,
1679                    "in",
1680                    &body,
1681                    sent_at,
1682                );
1683                let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
1684                self.maybe_emit_mention(room_id, &body);
1685                let _ = self.app_event_tx.send(AppEvent::MessageReceived {
1686                    room_id: room_id.to_string(),
1687                    sender_fingerprint,
1688                    body,
1689                    sent_at,
1690                });
1691            }
1692            RoomMessage::Typing { sender_fingerprint } => {
1693                if sender_fingerprint == our_fp {
1694                    return;
1695                }
1696                let expiry = now_unix() + TYPING_TTL_SECS;
1697                let mut rooms = self.active_rooms.lock().unwrap();
1698                if let Some(room) = rooms.get_mut(room_id) {
1699                    room.typers.insert(sender_fingerprint, expiry);
1700                }
1701                drop(rooms);
1702                let _ = self.app_event_tx.send(AppEvent::TypingChanged {
1703                    room_id: room_id.to_string(),
1704                });
1705            }
1706            RoomMessage::RotateRoomKey {
1707                rotator_fingerprint,
1708                new_salt,
1709            } => {
1710                if rotator_fingerprint == our_fp {
1711                    return;
1712                }
1713                // Rotations are self-attested: the signer must be the
1714                // claimed rotator. Unsigned forgeries land in
1715                // `verified_signer = None` and are dropped here, as are
1716                // signed envelopes where the signer fp doesn't match.
1717                let signer = match verified_signer {
1718                    Some(fp) => fp,
1719                    None => {
1720                        warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
1721                        return;
1722                    }
1723                };
1724                if signer != rotator_fingerprint {
1725                    warn!(
1726                        %signer, %rotator_fingerprint, %room_id,
1727                        "RotateRoomKey signer mismatch with claimed rotator; dropping"
1728                    );
1729                    return;
1730                }
1731                let _ = self.app_event_tx.send(AppEvent::RotationRequested {
1732                    room_id: room_id.to_string(),
1733                    rotator_fingerprint,
1734                    new_salt,
1735                });
1736            }
1737            RoomMessage::MemberLeave { sender_fingerprint } => {
1738                if sender_fingerprint == our_fp {
1739                    return;
1740                }
1741                let removed = {
1742                    let mut rooms = self.active_rooms.lock().unwrap();
1743                    if let Some(room) = rooms.get_mut(room_id) {
1744                        room.members.remove(&sender_fingerprint)
1745                    } else {
1746                        false
1747                    }
1748                };
1749                if removed {
1750                    let _ = self.app_event_tx.send(AppEvent::MemberLeft {
1751                        room_id: room_id.to_string(),
1752                        fingerprint: sender_fingerprint,
1753                    });
1754                }
1755            }
1756            RoomMessage::FileOffer {
1757                sender_fingerprint,
1758                file_id,
1759                name,
1760                size_bytes,
1761                mime,
1762                chunk_count,
1763                encrypted_meta,
1764            } => {
1765                if sender_fingerprint == our_fp {
1766                    return; // ignore our own broadcast
1767                }
1768                self.handle_file_offer(
1769                    room_id,
1770                    sender_fingerprint,
1771                    file_id,
1772                    name,
1773                    size_bytes,
1774                    mime,
1775                    chunk_count,
1776                    encrypted_meta,
1777                );
1778            }
1779            RoomMessage::FileChunk {
1780                sender_fingerprint,
1781                file_id,
1782                chunk_index,
1783                total_chunks,
1784                data_b64,
1785            } => {
1786                if sender_fingerprint == our_fp {
1787                    return;
1788                }
1789                self.handle_file_chunk(
1790                    room_id,
1791                    sender_fingerprint,
1792                    file_id,
1793                    chunk_index,
1794                    total_chunks,
1795                    data_b64,
1796                );
1797            }
1798            RoomMessage::OwnerGrant {
1799                room_id: announced_room_id,
1800                target_fingerprint,
1801            } => {
1802                // Both: payload room_id must match the topic's room_id
1803                // (no cross-room replay), AND the signer must be a
1804                // current owner of this room. Unsigned forgeries land in
1805                // `verified_signer = None` and are dropped here.
1806                if announced_room_id != room_id {
1807                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
1808                    return;
1809                }
1810                let signer = match verified_signer {
1811                    Some(fp) => fp,
1812                    None => {
1813                        warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
1814                        return;
1815                    }
1816                };
1817                if !self.is_owner(room_id, &signer) {
1818                    warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
1819                    return;
1820                }
1821                info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
1822                if let Err(e) =
1823                    repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
1824                {
1825                    warn!(%e, "OwnerGrant: set_member_role failed");
1826                }
1827            }
1828            RoomMessage::BanMember {
1829                room_id: announced_room_id,
1830                target_fingerprint,
1831            } => {
1832                if announced_room_id != room_id {
1833                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
1834                    return;
1835                }
1836                let signer = match verified_signer {
1837                    Some(fp) => fp,
1838                    None => {
1839                        warn!(%room_id, "BanMember arrived unsigned; dropping");
1840                        return;
1841                    }
1842                };
1843                if !self.is_owner(room_id, &signer) {
1844                    warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
1845                    return;
1846                }
1847                if target_fingerprint == our_fp {
1848                    // We've been kicked. Locally evict ourselves so the
1849                    // TUI tabs close; the kicker's subsequent
1850                    // RotateRoomKey will arrive separately and we
1851                    // simply won't be able to decrypt the new key,
1852                    // matching the "soft kick" semantics.
1853                    info!(%room_id, %signer, "we were kicked from this room");
1854                    self.active_rooms.lock().unwrap().remove(room_id);
1855                    let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1856                        room_id: room_id.to_string(),
1857                    });
1858                    return;
1859                }
1860                info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
1861                if let Err(e) = repo::add_room_ban(
1862                    &self.db,
1863                    room_id,
1864                    &target_fingerprint,
1865                    &signer,
1866                    "", // signature lives in the envelope, not the row
1867                    now_unix(),
1868                ) {
1869                    warn!(%e, "BanMember: add_room_ban failed");
1870                }
1871                self.evict_banned_member(room_id, &target_fingerprint);
1872            }
1873            RoomMessage::SasInit {
1874                tx_id,
1875                ephemeral_x25519_pubkey_b64,
1876                target_fingerprint,
1877            } => {
1878                if target_fingerprint != our_fp {
1879                    // Not addressed to us — ignore. Phase G is point-
1880                    // to-point even though it travels over the room
1881                    // topic, so members of the room who aren't the
1882                    // target don't need to act.
1883                    return;
1884                }
1885                let signer = match verified_signer {
1886                    Some(fp) => fp,
1887                    None => {
1888                        warn!("SasInit arrived unsigned; dropping");
1889                        return;
1890                    }
1891                };
1892                let their_pub =
1893                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
1894                        Ok(pk) => pk,
1895                        Err(e) => {
1896                            warn!(%e, "SasInit: bad x25519 pubkey");
1897                            return;
1898                        }
1899                    };
1900                let tx_id_bytes = match B64.decode(&tx_id) {
1901                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
1902                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
1903                        arr.copy_from_slice(&b);
1904                        arr
1905                    }
1906                    _ => {
1907                        warn!(%tx_id, "SasInit: bad tx_id length");
1908                        return;
1909                    }
1910                };
1911                let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
1912                let sas_code =
1913                    crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
1914                self.sas_flows.lock().unwrap().insert(
1915                    tx_id.clone(),
1916                    SasFlow {
1917                        room_id: room_id.to_string(),
1918                        partner_fingerprint: signer.clone(),
1919                        our_secret,
1920                        sas_code: Some(sas_code.clone()),
1921                        our_confirmed: false,
1922                        their_confirmed: false,
1923                    },
1924                );
1925                // Respond with our pubkey so the initiator can compute
1926                // the same code.
1927                let response = RoomMessage::SasResponse {
1928                    tx_id: tx_id.clone(),
1929                    ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
1930                };
1931                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
1932                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
1933                        self.network
1934                            .publish_room_message(room_id.to_string(), bytes)
1935                            .await;
1936                    }
1937                }
1938                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
1939                    room_id: room_id.to_string(),
1940                    partner_fingerprint: signer,
1941                    tx_id,
1942                    emoji_string: sas_code.emoji_string(),
1943                    emoji_labels: sas_code.emoji_labels(),
1944                    decimal: sas_code.decimal,
1945                });
1946            }
1947            RoomMessage::SasResponse {
1948                tx_id,
1949                ephemeral_x25519_pubkey_b64,
1950            } => {
1951                let signer = match verified_signer {
1952                    Some(fp) => fp,
1953                    None => {
1954                        warn!("SasResponse arrived unsigned; dropping");
1955                        return;
1956                    }
1957                };
1958                let their_pub =
1959                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
1960                        Ok(pk) => pk,
1961                        Err(e) => {
1962                            warn!(%e, "SasResponse: bad x25519 pubkey");
1963                            return;
1964                        }
1965                    };
1966                let tx_id_bytes = match B64.decode(&tx_id) {
1967                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
1968                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
1969                        arr.copy_from_slice(&b);
1970                        arr
1971                    }
1972                    _ => return,
1973                };
1974                let emit = {
1975                    let mut flows = self.sas_flows.lock().unwrap();
1976                    let flow = match flows.get_mut(&tx_id) {
1977                        Some(f) => f,
1978                        None => {
1979                            warn!(%tx_id, "SasResponse for unknown tx_id");
1980                            return;
1981                        }
1982                    };
1983                    if flow.partner_fingerprint != signer {
1984                        warn!(
1985                            expected = %flow.partner_fingerprint, got = %signer,
1986                            "SasResponse signer doesn't match flow's partner; dropping"
1987                        );
1988                        return;
1989                    }
1990                    let code = crate::crypto::sas::derive_sas_code(
1991                        &flow.our_secret,
1992                        &their_pub,
1993                        &tx_id_bytes,
1994                    );
1995                    flow.sas_code = Some(code.clone());
1996                    code
1997                };
1998                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
1999                    room_id: room_id.to_string(),
2000                    partner_fingerprint: signer,
2001                    tx_id,
2002                    emoji_string: emit.emoji_string(),
2003                    emoji_labels: emit.emoji_labels(),
2004                    decimal: emit.decimal,
2005                });
2006            }
2007            RoomMessage::CodeJoinRequest {
2008                room_id: announced_room_id,
2009                joiner_x25519_pubkey_b64,
2010                code,
2011            } => {
2012                if announced_room_id != room_id {
2013                    return;
2014                }
2015                let joiner_fp = match verified_signer {
2016                    Some(fp) => fp,
2017                    None => {
2018                        warn!("CodeJoinRequest unsigned; dropping");
2019                        return;
2020                    }
2021                };
2022                // Only owners with an active code are interested in
2023                // responding. Other peers (incl. non-issuing owners)
2024                // simply ignore.
2025                let our_fp = self.identity.fingerprint().to_string();
2026                if !self.is_owner(room_id, &our_fp) {
2027                    return;
2028                }
2029                // Match + consume the code. Single use.
2030                let now = now_unix();
2031                let (code_ok, our_session_id, wrap_input) = {
2032                    let mut rooms = self.active_rooms.lock().unwrap();
2033                    let room = match rooms.get_mut(room_id) {
2034                        Some(r) => r,
2035                        None => return,
2036                    };
2037                    if room.passphrase_key.is_none() {
2038                        warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2039                        return;
2040                    }
2041                    let original_len = room.issued_codes.len();
2042                    room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2043                    let matched = room.issued_codes.len() < original_len;
2044                    if !matched {
2045                        info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2046                        return;
2047                    }
2048                    let crypto = room.crypto.as_ref().unwrap();
2049                    (
2050                        true,
2051                        crypto.our_session_id(),
2052                        crypto.our_session_key_b64(),
2053                    )
2054                };
2055                let _ = code_ok;
2056                // ECDH with the joiner's ephemeral pubkey.
2057                let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2058                    Ok(pk) => pk,
2059                    Err(e) => {
2060                        warn!(%e, "CodeJoinRequest: bad pubkey");
2061                        return;
2062                    }
2063                };
2064                use x25519_dalek::{PublicKey, StaticSecret};
2065                let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2066                let our_pub = PublicKey::from(&our_secret);
2067                let shared = our_secret.diffie_hellman(&their_pub);
2068                // HKDF the shared secret into a 32-byte wrap key.
2069                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2070                let mut wrap_key = [0u8; passphrase::KEY_LEN];
2071                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2072                    .expect("32 bytes is within HKDF limits");
2073                // Wrap our session key under the ECDH-derived key,
2074                // reusing the existing AEAD primitives.
2075                let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2076                    Ok(w) => w,
2077                    Err(e) => {
2078                        warn!(%e, "CodeJoinRequest: wrap failed");
2079                        return;
2080                    }
2081                };
2082                let response = RoomMessage::CodeJoinResponse {
2083                    room_id: room_id.to_string(),
2084                    target_fingerprint: joiner_fp.clone(),
2085                    owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2086                    owner_session_id: our_session_id,
2087                    wrapped_session_key_b64: wrapped,
2088                    nonce_b64: String::new(), // nonce is embedded in `wrapped` per passphrase::wrap
2089                };
2090                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2091                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2092                        self.network
2093                            .publish_room_message(room_id.to_string(), bytes)
2094                            .await;
2095                    }
2096                }
2097                info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2098            }
2099            RoomMessage::CodeJoinResponse {
2100                room_id: announced_room_id,
2101                target_fingerprint,
2102                owner_x25519_pubkey_b64,
2103                owner_session_id,
2104                wrapped_session_key_b64,
2105                nonce_b64: _,
2106            } => {
2107                if announced_room_id != room_id || target_fingerprint != our_fp {
2108                    return;
2109                }
2110                let owner_fp = match verified_signer {
2111                    Some(fp) => fp,
2112                    None => {
2113                        warn!("CodeJoinResponse unsigned; dropping");
2114                        return;
2115                    }
2116                };
2117                let our_secret = match self
2118                    .pending_code_secrets
2119                    .lock()
2120                    .unwrap()
2121                    .remove(&(room_id.to_string(), our_fp.clone()))
2122                {
2123                    Some(s) => s,
2124                    None => {
2125                        warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2126                        return;
2127                    }
2128                };
2129                let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2130                    Ok(pk) => pk,
2131                    Err(e) => {
2132                        warn!(%e, "CodeJoinResponse: bad owner pubkey");
2133                        return;
2134                    }
2135                };
2136                let shared = our_secret.diffie_hellman(&owner_pub);
2137                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2138                let mut wrap_key = [0u8; passphrase::KEY_LEN];
2139                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2140                    .expect("32 bytes within HKDF limits");
2141                let session_key_bytes =
2142                    match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2143                        Ok(b) => b,
2144                        Err(e) => {
2145                            warn!(%e, "CodeJoinResponse: unwrap failed");
2146                            return;
2147                        }
2148                    };
2149                let session_key_str = match String::from_utf8(session_key_bytes) {
2150                    Ok(s) => s,
2151                    Err(e) => {
2152                        warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
2153                        return;
2154                    }
2155                };
2156                // Install as an inbound session keyed by the owner's fp.
2157                let mut rooms = self.active_rooms.lock().unwrap();
2158                if let Some(room) = rooms.get_mut(room_id) {
2159                    if let Some(crypto) = room.crypto.as_mut() {
2160                        if let Err(e) =
2161                            crypto.add_inbound_session(&owner_fp, &session_key_str)
2162                        {
2163                            warn!(%e, "CodeJoinResponse: add_inbound_session failed");
2164                        } else {
2165                            info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
2166                            room.members.insert(owner_fp.clone());
2167                            let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2168                                room_id: room_id.to_string(),
2169                                fingerprint: owner_fp,
2170                            });
2171                        }
2172                    }
2173                }
2174            }
2175            RoomMessage::JoinRefused {
2176                room_id: announced_room_id,
2177                target_fingerprint,
2178                reason,
2179            } => {
2180                if announced_room_id != room_id || target_fingerprint != our_fp {
2181                    return;
2182                }
2183                // Surface the refusal as an Error so the user sees why
2184                // their join didn't take. The Phase 3 modal-queue rule
2185                // means this won't clobber typing in another modal.
2186                let _ = self.app_event_tx.send(AppEvent::Error {
2187                    description: format!("join refused: {reason}"),
2188                });
2189            }
2190            RoomMessage::SasConfirm { tx_id, matched } => {
2191                let signer = match verified_signer {
2192                    Some(fp) => fp,
2193                    None => return,
2194                };
2195                let (room_id_done, partner_fp_done, both_done) = {
2196                    let mut flows = self.sas_flows.lock().unwrap();
2197                    let flow = match flows.get_mut(&tx_id) {
2198                        Some(f) => f,
2199                        None => return,
2200                    };
2201                    if flow.partner_fingerprint != signer {
2202                        return;
2203                    }
2204                    if !matched {
2205                        // Partner declined / mismatch — drop the flow.
2206                        let _ = flow;
2207                        flows.remove(&tx_id);
2208                        return;
2209                    }
2210                    flow.their_confirmed = true;
2211                    if flow.our_confirmed && flow.their_confirmed {
2212                        (
2213                            Some(flow.room_id.clone()),
2214                            Some(flow.partner_fingerprint.clone()),
2215                            true,
2216                        )
2217                    } else {
2218                        (None, None, false)
2219                    }
2220                };
2221                if both_done {
2222                    if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
2223                        if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
2224                            warn!(%e, "finish_sas failed");
2225                        }
2226                    }
2227                }
2228            }
2229        }
2230    }
2231
2232    // -------------------------------------------------------------------
2233    // File transfer — public API
2234    // -------------------------------------------------------------------
2235
2236    /// Send a local file to a room. Reads the file, optionally encrypts
2237    /// it for encrypted rooms, chunks it, broadcasts a FileOffer then
2238    /// each FileChunk. Returns the file_id once all chunks are queued.
2239    pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
2240        let bytes = std::fs::read(path)?;
2241        let name = path
2242            .file_name()
2243            .map(|n| n.to_string_lossy().to_string())
2244            .unwrap_or_else(|| "untitled".into());
2245        let mime = crate::files::guess_mime(&name);
2246        let original_path = path.to_path_buf();
2247
2248        let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
2249            let mut rooms = self.active_rooms.lock().unwrap();
2250            let room = rooms
2251                .get_mut(room_id)
2252                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2253            if room.info.encrypted {
2254                let crypto = room
2255                    .crypto
2256                    .as_mut()
2257                    .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
2258                let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
2259                (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
2260            } else {
2261                (false, None, None, bytes)
2262            }
2263        };
2264        let _ = &mut maybe_session_id; // silence unused warning when non-encrypted
2265
2266        let plan =
2267            self.file_manager
2268                .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
2269        let file_id = plan.file_id.clone();
2270        let total = plan.chunks.len() as u32;
2271        let our_fp = self.identity.fingerprint().to_string();
2272
2273        let attachment = StoredAttachment {
2274            id: 0,
2275            room_id: room_id.to_string(),
2276            message_id: None,
2277            sender_fingerprint: our_fp.clone(),
2278            file_id: file_id.clone(),
2279            name: name.clone(),
2280            mime: mime.clone(),
2281            size_bytes: plan.size_bytes as i64,
2282            status: AttachmentStatus::Ready,
2283            cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
2284            saved_path: Some(original_path.to_string_lossy().into()),
2285            error: None,
2286            encrypted: room_encrypted,
2287            wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
2288            nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
2289            megolm_session_id: encrypted_meta_opt
2290                .as_ref()
2291                .map(|m| m.megolm_session_id.clone()),
2292            content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
2293            created_at: now_unix(),
2294        };
2295        repo::upsert_attachment(&self.db, &attachment)?;
2296        let _ = self.app_event_tx.send(AppEvent::FileOffered {
2297            room_id: room_id.to_string(),
2298            file_id: file_id.clone(),
2299            name: name.clone(),
2300            size_bytes: plan.size_bytes,
2301            sender_fingerprint: our_fp.clone(),
2302        });
2303
2304        // Publish the offer.
2305        let offer = RoomMessage::FileOffer {
2306            sender_fingerprint: our_fp.clone(),
2307            file_id: file_id.clone(),
2308            name,
2309            size_bytes: plan.size_bytes,
2310            mime,
2311            chunk_count: total,
2312            encrypted_meta: encrypted_meta_opt,
2313        };
2314        if let Ok(bytes) = encode_wire(&offer) {
2315            self.network
2316                .publish_room_message(room_id.to_string(), bytes)
2317                .await;
2318        }
2319
2320        // Stream chunks. Brief pacing so gossipsub doesn't see a thundering
2321        // herd from a single peer.
2322        let net = self.network.clone();
2323        let room = room_id.to_string();
2324        let our = our_fp.clone();
2325        let fid = file_id.clone();
2326        let chunks = plan.chunks.clone();
2327        tokio::spawn(async move {
2328            for (i, data) in chunks.iter().enumerate() {
2329                let msg = RoomMessage::FileChunk {
2330                    sender_fingerprint: our.clone(),
2331                    file_id: fid.clone(),
2332                    chunk_index: i as u32,
2333                    total_chunks: total,
2334                    data_b64: B64.encode(data),
2335                };
2336                if let Ok(bytes) = encode_wire(&msg) {
2337                    net.publish_room_message(room.clone(), bytes).await;
2338                }
2339                tokio::time::sleep(Duration::from_millis(40)).await;
2340            }
2341        });
2342
2343        Ok(file_id)
2344    }
2345
2346    /// Save a completed/ready attachment to the user's Downloads folder.
2347    /// Decrypts encrypted attachments on the way out.
2348    pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
2349        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2350            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2351        if !matches!(
2352            attachment.status,
2353            AttachmentStatus::Ready | AttachmentStatus::Saved
2354        ) {
2355            return Err(HuddleError::Other(format!(
2356                "attachment is not ready (status={})",
2357                attachment.status.as_str()
2358            )));
2359        }
2360        // Our own encrypted attachment: the file_manager cache holds the
2361        // ciphertext and we have no inbound Megolm session keyed by
2362        // ourselves, so it can't be decrypted back. But `saved_path` still
2363        // points at the original plaintext we sent — copy from there.
2364        let plaintext = if attachment.encrypted
2365            && attachment.sender_fingerprint == self.identity.fingerprint()
2366        {
2367            match attachment
2368                .saved_path
2369                .as_deref()
2370                .filter(|p| Path::new(p).exists())
2371            {
2372                Some(src) => std::fs::read(src)?,
2373                None => {
2374                    return Err(HuddleError::Other(
2375                        "your original file has moved or been deleted — it can't be \
2376                         recovered from the encrypted cache"
2377                            .into(),
2378                    ));
2379                }
2380            }
2381        } else {
2382            let cached = self.file_manager.read_cache(file_id)?;
2383            if attachment.encrypted {
2384                let meta = EncryptedFileMeta {
2385                    megolm_session_id: attachment
2386                        .megolm_session_id
2387                        .clone()
2388                        .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
2389                    wrapped_key_b64: attachment
2390                        .wrapped_key
2391                        .clone()
2392                        .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
2393                    nonce_b64: attachment
2394                        .nonce
2395                        .clone()
2396                        .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
2397                    content_hash: attachment
2398                        .content_hash
2399                        .clone()
2400                        .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
2401                };
2402                self.decrypt_attachment(
2403                    room_id,
2404                    &attachment.sender_fingerprint,
2405                    &cached,
2406                    &meta,
2407                )?
2408            } else {
2409                cached
2410            }
2411        };
2412        let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
2413        repo::update_attachment_paths(
2414            &self.db,
2415            room_id,
2416            file_id,
2417            None,
2418            Some(&saved.to_string_lossy()),
2419        )?;
2420        repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
2421        let _ = self.app_event_tx.send(AppEvent::FileSaved {
2422            file_id: file_id.into(),
2423            path: saved.to_string_lossy().into(),
2424        });
2425        Ok(saved)
2426    }
2427
2428    /// Drop any in-flight chunks and remove the attachment row.
2429    pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
2430        self.file_manager.cancel_incoming(file_id);
2431        repo::update_attachment_status(
2432            &self.db,
2433            room_id,
2434            file_id,
2435            AttachmentStatus::Cancelled,
2436            None,
2437        )?;
2438        Ok(())
2439    }
2440
2441    /// Launch the system's default opener on a saved file.
2442    pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
2443        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
2444            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
2445        let path = attachment
2446            .saved_path
2447            .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
2448        open_with_system(&path)
2449    }
2450
2451    pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
2452        repo::list_room_attachments(&self.db, room_id)
2453    }
2454
2455    /// Mark a peer's fingerprint as verified in the given room. Used by
2456    /// the `^V` verification modal after the user has compared the
2457    /// fingerprint out-of-band.
2458    pub fn set_member_verified(
2459        &self,
2460        room_id: &str,
2461        fingerprint: &str,
2462        verified: bool,
2463    ) -> Result<()> {
2464        // Make sure there's a member row to flip — peer_id is unknown
2465        // at this layer when the user verifies an out-of-band identity,
2466        // so we use the fingerprint as the canonical identity key with
2467        // an empty peer_id placeholder if none exists.
2468        let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
2469        if !members.iter().any(|m| m.fingerprint == fingerprint) {
2470            repo::upsert_room_member(
2471                &self.db,
2472                &StoredRoomMember {
2473                    room_id: room_id.to_string(),
2474                    peer_id: String::new(),
2475                    fingerprint: fingerprint.to_string(),
2476                    last_seen: Some(now_unix()),
2477                    verified,
2478                    ed25519_pubkey: None,
2479                    role: "member".into(),
2480                },
2481            )?;
2482        }
2483        repo::set_member_verified(&self.db, room_id, fingerprint, verified)
2484    }
2485
2486    pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
2487        repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
2488    }
2489
2490    /// Phase B: is `fingerprint` an owner of `room_id`? Used by the TUI
2491    /// to gate `^K` / `^G` and the kick/grant member-picker actions.
2492    pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
2493        repo::list_room_owners(&self.db, room_id)
2494            .unwrap_or_default()
2495            .iter()
2496            .any(|fp| fp == fingerprint)
2497    }
2498
2499    pub fn we_are_owner(&self, room_id: &str) -> bool {
2500        self.is_owner(room_id, &self.identity.fingerprint().to_string())
2501    }
2502
2503    /// Phase B: list current owner fingerprints for `room_id` — used to
2504    /// render an owner badge in the member panel.
2505    pub fn room_owners(&self, room_id: &str) -> Vec<String> {
2506        repo::list_room_owners(&self.db, room_id).unwrap_or_default()
2507    }
2508
2509    /// Phase E: global toggle — when true, inbound dials from
2510    /// unverified fingerprints are auto-rejected without prompting.
2511    pub fn verified_only_inbound(&self) -> bool {
2512        repo::get_setting(&self.db, "verified_only_inbound")
2513            .unwrap_or(None)
2514            .map(|v| v == "1")
2515            .unwrap_or(false)
2516    }
2517
2518    pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
2519        repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
2520    }
2521
2522    /// Phase E: per-room verified-only-join. When true, the host (and
2523    /// every honest existing member) drops MemberAnnounce from joiners
2524    /// who aren't globally SAS-verified, and the lowest-fp owner sends
2525    /// back a signed `JoinRefused` so the joiner sees an explanation.
2526    pub fn room_verified_only(&self, room_id: &str) -> bool {
2527        repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2528    }
2529
2530    pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
2531        repo::set_room_verified_only(&self.db, room_id, on)
2532    }
2533
2534    /// Phase H: first-launch onboarding flag.
2535    pub fn onboarding_seen(&self) -> bool {
2536        repo::is_onboarding_seen(&self.db).unwrap_or(true)
2537    }
2538
2539    pub fn mark_onboarding_seen(&self) -> Result<()> {
2540        repo::mark_onboarding_seen(&self.db)
2541    }
2542
2543    /// Phase B: promote `target_fingerprint` to owner. Builds a signed
2544    /// `OwnerGrant`, broadcasts it, and applies it locally. Returns an
2545    /// error if we ourselves aren't an owner — only owners can grant.
2546    pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
2547        let our_fp = self.identity.fingerprint().to_string();
2548        if !self.is_owner(room_id, &our_fp) {
2549            return Err(HuddleError::Other(
2550                "only an owner can grant owner".into(),
2551            ));
2552        }
2553        let msg = RoomMessage::OwnerGrant {
2554            room_id: room_id.to_string(),
2555            target_fingerprint: target_fingerprint.to_string(),
2556        };
2557        let env = crate::crypto::sign_message(&self.identity, &msg)?;
2558        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2559        self.network
2560            .publish_room_message(room_id.to_string(), bytes)
2561            .await;
2562        // Apply locally too — peers will converge on the next announce.
2563        repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
2564        Ok(())
2565    }
2566
2567    /// Phase B: kick `target_fingerprint` from `room_id`. Broadcasts a
2568    /// signed `BanMember`, records the ban locally, then immediately
2569    /// rotates the room key under a freshly-generated passphrase. Returns
2570    /// the new passphrase so the caller can show it to the owner for
2571    /// out-of-band sharing with remaining members.
2572    ///
2573    /// The rotation is the cryptographic enforcement: a banned peer can
2574    /// still subscribe to the gossipsub topic and see the ciphertext,
2575    /// but they can't unwrap the new session key without the new
2576    /// passphrase, so they can't decrypt anything sent after the kick.
2577    pub async fn kick_member(
2578        &self,
2579        room_id: &str,
2580        target_fingerprint: &str,
2581    ) -> Result<String> {
2582        let our_fp = self.identity.fingerprint().to_string();
2583        if !self.is_owner(room_id, &our_fp) {
2584            return Err(HuddleError::Other("only an owner can kick".into()));
2585        }
2586        if target_fingerprint == our_fp {
2587            return Err(HuddleError::Other("can't kick yourself".into()));
2588        }
2589        let info = self
2590            .active_rooms
2591            .lock()
2592            .unwrap()
2593            .get(room_id)
2594            .map(|r| r.info.clone())
2595            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2596        if !info.encrypted {
2597            // Without a key to rotate, a "kick" is purely advisory —
2598            // ban only. Honest clients drop their messages, but anyone
2599            // can still read the room. Honest in v1; documented.
2600            let msg = RoomMessage::BanMember {
2601                room_id: room_id.to_string(),
2602                target_fingerprint: target_fingerprint.to_string(),
2603            };
2604            let env = crate::crypto::sign_message(&self.identity, &msg)?;
2605            let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2606            self.network
2607                .publish_room_message(room_id.to_string(), bytes)
2608                .await;
2609            repo::add_room_ban(
2610                &self.db,
2611                room_id,
2612                target_fingerprint,
2613                &our_fp,
2614                &env.signature_b64,
2615                now_unix(),
2616            )?;
2617            self.evict_banned_member(room_id, target_fingerprint);
2618            return Ok(String::new());
2619        }
2620        // Encrypted room — full kick path.
2621        let new_passphrase = generate_join_passphrase();
2622        let msg = RoomMessage::BanMember {
2623            room_id: room_id.to_string(),
2624            target_fingerprint: target_fingerprint.to_string(),
2625        };
2626        let env = crate::crypto::sign_message(&self.identity, &msg)?;
2627        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2628        self.network
2629            .publish_room_message(room_id.to_string(), bytes)
2630            .await;
2631        repo::add_room_ban(
2632            &self.db,
2633            room_id,
2634            target_fingerprint,
2635            &our_fp,
2636            &env.signature_b64,
2637            now_unix(),
2638        )?;
2639        self.evict_banned_member(room_id, target_fingerprint);
2640        // Reuse the existing rotation flow so all the existing salt /
2641        // session / persistence logic stays in one place.
2642        self.rotate_room(room_id, &new_passphrase).await?;
2643        Ok(new_passphrase)
2644    }
2645
2646    /// Phase F: generate an 8-char alphanumeric join code for `room_id`,
2647    /// good for 10 minutes. Stored in memory only on the issuing owner's
2648    /// machine — a single use clears it. Caller is responsible for
2649    /// sharing the code OOB with the prospective joiner.
2650    ///
2651    /// Owner-only. Errors if `room_id` isn't active or we're not an owner.
2652    pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
2653        let our_fp = self.identity.fingerprint().to_string();
2654        if !self.is_owner(room_id, &our_fp) {
2655            return Err(HuddleError::Other(
2656                "only an owner can issue join codes".into(),
2657            ));
2658        }
2659        let code = generate_alphanumeric_code(8);
2660        let expires_at = now_unix() + 10 * 60;
2661        let mut rooms = self.active_rooms.lock().unwrap();
2662        let room = rooms
2663            .get_mut(room_id)
2664            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
2665        // Prune expired entries while we're here so the list doesn't grow.
2666        let now = now_unix();
2667        room.issued_codes.retain(|(_, exp)| *exp > now);
2668        room.issued_codes.push((code.clone(), expires_at));
2669        Ok(code)
2670    }
2671
2672    /// Phase F: join `room_id` using a short-lived code instead of the
2673    /// passphrase. Generates an ephemeral X25519 keypair, broadcasts a
2674    /// signed `CodeJoinRequest`, and waits for the owner's
2675    /// `CodeJoinResponse`. The receive arm builds an `ActiveRoom`
2676    /// flagged read-only (no passphrase = can't share our outbound
2677    /// session key with others).
2678    pub async fn join_room_with_code(
2679        &self,
2680        room_id: &str,
2681        code: &str,
2682    ) -> Result<()> {
2683        // Resolve discovered metadata so we know name/encrypted/etc.
2684        let info = {
2685            let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
2686            match d {
2687                Some(d) => StoredRoom {
2688                    id: room_id.to_string(),
2689                    name: d.name,
2690                    creator_fingerprint: d.creator_fingerprint,
2691                    encrypted: d.encrypted,
2692                    passphrase_salt: None, // unused on code-join path
2693                    created_at: now_unix(),
2694                    last_active: Some(now_unix()),
2695                },
2696                None => {
2697                    return Err(HuddleError::Other(format!(
2698                        "room {room_id} not visible — wait for an announcement"
2699                    )))
2700                }
2701            }
2702        };
2703        if !info.encrypted {
2704            return Err(HuddleError::Other(
2705                "code-join only applies to encrypted rooms".into(),
2706            ));
2707        }
2708        let our_fp = self.identity.fingerprint().to_string();
2709        // Generate ephemeral X25519 keypair; remember the secret so the
2710        // CodeJoinResponse receive arm can complete ECDH on this peer.
2711        use x25519_dalek::{PublicKey, StaticSecret};
2712        let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2713        let our_pub = PublicKey::from(&our_secret);
2714        // Stash the secret keyed by (room_id, our_fp); the response
2715        // handler removes the matching entry when a response targeted
2716        // at us arrives. The composite key means a second joiner can
2717        // be in flight in the same room without overwriting our state.
2718        let key = (room_id.to_string(), our_fp.clone());
2719        self.pending_code_secrets
2720            .lock()
2721            .unwrap()
2722            .insert(key.clone(), our_secret);
2723        // Code-join timeout: if no response in 30s, the entry will
2724        // still be in the map (the response handler removes it on
2725        // success). Surface a `CodeJoinTimedOut` to the TUI so the
2726        // user isn't stuck staring at an empty room expecting traffic.
2727        let map = self.pending_code_secrets.clone();
2728        let tx = self.app_event_tx.clone();
2729        let timeout_room = room_id.to_string();
2730        tokio::spawn(async move {
2731            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
2732            let still_pending = map.lock().unwrap().remove(&key).is_some();
2733            if still_pending {
2734                let _ = tx.send(AppEvent::CodeJoinTimedOut {
2735                    room_id: timeout_room,
2736                    reason: "no response from owner — code may be wrong or expired".into(),
2737                });
2738            }
2739        });
2740        // Persist the rooms row BEFORE constructing RoomCrypto, whose
2741        // `persist_outbound()` writes a `room_megolm_sessions` row with
2742        // a FK to `rooms(id)`. Without this, the FK fires and the
2743        // join aborts. The salt is left None for now — we don't have
2744        // the passphrase and the announcing peer's salt is cached in
2745        // ROOM_SALT_CACHE for whenever we get re-onboarded.
2746        repo::insert_room(&self.db, &info)?;
2747        // Create a placeholder ActiveRoom with no crypto yet; we'll
2748        // fill in the inbound session in the response handler.
2749        self.active_rooms.lock().unwrap().insert(
2750            room_id.to_string(),
2751            ActiveRoom {
2752                info: info.clone(),
2753                crypto: Some(RoomCrypto::new_for_room(
2754                    self.db.clone(),
2755                    room_id.to_string(),
2756                    our_fp.clone(),
2757                    self.session_persist_key,
2758                )?),
2759                passphrase_key: None,
2760                members: {
2761                    let mut s = HashSet::new();
2762                    s.insert(our_fp.clone());
2763                    s
2764                },
2765                typers: HashMap::new(),
2766                read_only: true,
2767                issued_codes: Vec::new(),
2768            },
2769        );
2770        self.network.subscribe_room(room_id.to_string()).await;
2771        // Broadcast the request.
2772        let req = RoomMessage::CodeJoinRequest {
2773            room_id: room_id.to_string(),
2774            joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2775            code: code.to_string(),
2776        };
2777        let env = crate::crypto::sign_message(&self.identity, &req)?;
2778        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2779        self.network
2780            .publish_room_message(room_id.to_string(), bytes)
2781            .await;
2782        // Emit RoomJoined so the TUI opens the tab. Subsequent ability
2783        // to read messages depends on receiving the owner's response.
2784        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
2785            room_id: room_id.to_string(),
2786        });
2787        Ok(())
2788    }
2789
2790    /// Phase G: start an SAS verification with `target_fingerprint` in
2791    /// `room_id`. Returns the tx_id so the caller can correlate
2792    /// subsequent events. The full flow is asynchronous — the partner
2793    /// must accept on their end, both compute the ECDH-derived SAS
2794    /// code, OOB-compare it, and each press Match.
2795    pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
2796        let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
2797        let tx_id = B64.encode(tx_id_bytes);
2798        let msg = RoomMessage::SasInit {
2799            tx_id: tx_id.clone(),
2800            ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2801            target_fingerprint: target_fingerprint.to_string(),
2802        };
2803        let env = crate::crypto::sign_message(&self.identity, &msg)?;
2804        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2805        self.sas_flows.lock().unwrap().insert(
2806            tx_id.clone(),
2807            SasFlow {
2808                room_id: room_id.to_string(),
2809                partner_fingerprint: target_fingerprint.to_string(),
2810                our_secret,
2811                sas_code: None,
2812                our_confirmed: false,
2813                their_confirmed: false,
2814            },
2815        );
2816        self.network
2817            .publish_room_message(room_id.to_string(), bytes)
2818            .await;
2819        Ok(tx_id)
2820    }
2821
2822    /// Phase G: user pressed Match on the SAS code modal — broadcast our
2823    /// signed `SasConfirm{matched: true}`. If the partner has already
2824    /// matched, this completes verification on both sides.
2825    pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
2826        let (room_id, partner_fp, both_done) = {
2827            let mut flows = self.sas_flows.lock().unwrap();
2828            let flow = flows
2829                .get_mut(tx_id)
2830                .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
2831            flow.our_confirmed = true;
2832            (
2833                flow.room_id.clone(),
2834                flow.partner_fingerprint.clone(),
2835                flow.our_confirmed && flow.their_confirmed,
2836            )
2837        };
2838        let msg = RoomMessage::SasConfirm {
2839            tx_id: tx_id.to_string(),
2840            matched: true,
2841        };
2842        let env = crate::crypto::sign_message(&self.identity, &msg)?;
2843        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2844        self.network
2845            .publish_room_message(room_id.clone(), bytes)
2846            .await;
2847        if both_done {
2848            self.finish_sas(tx_id, &room_id, &partner_fp).await?;
2849        }
2850        Ok(())
2851    }
2852
2853    /// Phase G: cancel an in-flight SAS — drop our local state. Doesn't
2854    /// broadcast a "matched=false" notice in v1 (partner's flow stays
2855    /// dangling; they can cancel their side too). Quiet teardown.
2856    pub fn sas_cancel(&self, tx_id: &str) {
2857        self.sas_flows.lock().unwrap().remove(tx_id);
2858    }
2859
2860    /// Phase G internal: both sides have confirmed — flip the partner's
2861    /// fingerprint to verified (per-room AND global) and clean up.
2862    async fn finish_sas(
2863        &self,
2864        tx_id: &str,
2865        room_id: &str,
2866        partner_fingerprint: &str,
2867    ) -> Result<()> {
2868        repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
2869        repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
2870        self.sas_flows.lock().unwrap().remove(tx_id);
2871        let _ = self.app_event_tx.send(AppEvent::SasVerified {
2872            room_id: room_id.to_string(),
2873            partner_fingerprint: partner_fingerprint.to_string(),
2874        });
2875        Ok(())
2876    }
2877
2878    /// Phase B internal: drop a banned member's in-memory presence in a
2879    /// room. Persistent ban already went to `room_bans`. Called from
2880    /// `kick_member` (locally banning ourselves) and from the
2881    /// `RoomMessage::BanMember` receive arm (peer-initiated ban).
2882    fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
2883        if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
2884            room.members.remove(fingerprint);
2885        }
2886        let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2887            room_id: room_id.to_string(),
2888            fingerprint: fingerprint.to_string(),
2889        });
2890    }
2891
2892    pub fn display_name(&self) -> Option<String> {
2893        repo::get_display_name(&self.db).unwrap_or(None)
2894    }
2895
2896    pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
2897        repo::set_display_name(&self.db, name)
2898    }
2899
2900    /// Look up the display name we've seen for a peer in any room.
2901    pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
2902        repo::lookup_display_name(&self.db, fingerprint).unwrap_or(None)
2903    }
2904
2905    pub fn is_room_muted(&self, room_id: &str) -> bool {
2906        repo::is_room_muted(&self.db, room_id).unwrap_or(false)
2907    }
2908
2909    /// Phase B: list the fingerprints currently banned from a room
2910    /// (newest first). Backs the `^B` in-room view; intended for
2911    /// owners but the read itself is harmless and we let callers
2912    /// gate via `we_are_owner` if they want owner-only display.
2913    pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
2914        repo::list_room_bans(&self.db, room_id).unwrap_or_default()
2915    }
2916
2917    /// Phase A: list every globally-blocked peer (one fingerprint per
2918    /// row). Surfaced in the Settings modal alongside a clear-all
2919    /// action that calls `unblock_peer` in a loop.
2920    pub fn list_blocked_peers(&self) -> Vec<String> {
2921        repo::list_blocked_peers(&self.db).unwrap_or_default()
2922    }
2923
2924    /// Phase A: remove `fingerprint` from the persistent blocklist. The
2925    /// peer will no longer be auto-rejected on connection; they fall
2926    /// back to the regular inbound-dial accept/reject prompt.
2927    pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
2928        repo::unblock_peer(&self.db, fingerprint)
2929    }
2930
2931    /// Phase F: rooms entered via a join code don't have the passphrase
2932    /// in memory, so the joining peer can't wrap their own outbound
2933    /// session key for newer members — they can read and send, they
2934    /// just can't onboard others. The TUI renders a `(read-only)`
2935    /// badge in the room tab so the user understands.
2936    pub fn is_room_read_only(&self, room_id: &str) -> bool {
2937        self.active_rooms
2938            .lock()
2939            .unwrap()
2940            .get(room_id)
2941            .map(|r| r.read_only)
2942            .unwrap_or(false)
2943    }
2944
2945    pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
2946        repo::set_room_muted(&self.db, room_id, muted)
2947    }
2948
2949    /// Broadcast a "I'm typing" pulse to the given room. Caller is
2950    /// responsible for debouncing (don't fire more than every ~500ms).
2951    pub async fn broadcast_typing(&self, room_id: &str) {
2952        if !self.active_rooms.lock().unwrap().contains_key(room_id) {
2953            return;
2954        }
2955        let msg = RoomMessage::Typing {
2956            sender_fingerprint: self.identity.fingerprint().to_string(),
2957        };
2958        if let Ok(bytes) = encode_wire(&msg) {
2959            self.network
2960                .publish_room_message(room_id.to_string(), bytes)
2961                .await;
2962        }
2963    }
2964
2965    /// Returns the fingerprints of peers currently typing in `room_id`,
2966    /// pruning entries past their TTL.
2967    pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
2968        let now = now_unix();
2969        let mut rooms = self.active_rooms.lock().unwrap();
2970        let room = match rooms.get_mut(room_id) {
2971            Some(r) => r,
2972            None => return Vec::new(),
2973        };
2974        room.typers.retain(|_, exp| *exp > now);
2975        let mut v: Vec<String> = room.typers.keys().cloned().collect();
2976        v.sort();
2977        v
2978    }
2979
2980    // -------------------------------------------------------------------
2981    // Room key rotation
2982    // -------------------------------------------------------------------
2983
2984    /// Rotate this room's outbound Megolm session under a fresh
2985    /// passphrase. Broadcasts `RotateRoomKey` (so other members know to
2986    /// expect a new passphrase) and a fresh `MemberAnnounce` with the
2987    /// new wrapped session key. Old inbound sessions stay in storage
2988    /// for decrypting historic messages.
2989    pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
2990        if new_passphrase.is_empty() {
2991            return Err(HuddleError::Other("new passphrase is empty".into()));
2992        }
2993        let new_salt = passphrase::random_salt();
2994        let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
2995
2996        let info = {
2997            let mut rooms = self.active_rooms.lock().unwrap();
2998            let room = rooms
2999                .get_mut(room_id)
3000                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3001            if !room.info.encrypted {
3002                return Err(HuddleError::Other(
3003                    "rotation only applies to encrypted rooms".into(),
3004                ));
3005            }
3006            // Generate a fresh outbound Megolm session for this member.
3007            let new_crypto = RoomCrypto::new_for_room(
3008                self.db.clone(),
3009                room_id.to_string(),
3010                self.identity.fingerprint().to_string(),
3011                self.session_persist_key,
3012            )?;
3013            room.crypto = Some(new_crypto);
3014            room.passphrase_key = Some(new_key);
3015            room.info.passphrase_salt = Some(new_salt.to_vec());
3016            room.info.clone()
3017        };
3018
3019        // Broadcast before persisting: peers learn about the rotation even
3020        // if we crash before the DB write lands, and our own restore path
3021        // can recover from the persisted Megolm session plus the announced
3022        // salt. Persisting first would risk a DB row that's ahead of what
3023        // any peer knows.
3024        let rot = RoomMessage::RotateRoomKey {
3025            rotator_fingerprint: self.identity.fingerprint().to_string(),
3026            new_salt: new_salt.to_vec(),
3027        };
3028        // Signed: rotations are self-attested, so peers can prove the
3029        // claimed `rotator_fingerprint` really came from that identity.
3030        // An unsigned rotation is rejected on the receive side.
3031        if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
3032            if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3033                self.network
3034                    .publish_room_message(room_id.to_string(), bytes)
3035                    .await;
3036            }
3037        }
3038        // Re-announce ourselves with the new wrapped session key.
3039        if let Err(e) = self.broadcast_member_announce(room_id).await {
3040            warn!(%e, "rotate: broadcast announce failed");
3041        }
3042
3043        // Now persist the new salt on the stored row.
3044        repo::insert_room(&self.db, &info)?;
3045        Ok(())
3046    }
3047
3048    /// Used by the TUI when another member rotates a room we're in.
3049    /// Derives the new key, updates our local state, and re-announces
3050    /// so the rotator can share their fresh outbound session with us.
3051    pub async fn accept_rotation(
3052        &self,
3053        room_id: &str,
3054        new_salt: &[u8],
3055        new_passphrase: &str,
3056    ) -> Result<()> {
3057        let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
3058        let info = {
3059            let mut rooms = self.active_rooms.lock().unwrap();
3060            let room = rooms
3061                .get_mut(room_id)
3062                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3063            room.passphrase_key = Some(new_key);
3064            room.info.passphrase_salt = Some(new_salt.to_vec());
3065            room.info.clone()
3066        };
3067        // Ask the rotator (and anyone) to re-share their session key
3068        // before persisting, so a crash before the DB write still leaves
3069        // peers aware we've moved to the new salt.
3070        let req = RoomMessage::SessionKeyRequest {
3071            requester_fingerprint: self.identity.fingerprint().to_string(),
3072        };
3073        if let Ok(bytes) = encode_wire(&req) {
3074            self.network
3075                .publish_room_message(room_id.to_string(), bytes)
3076                .await;
3077        }
3078        repo::insert_room(&self.db, &info)?;
3079        Ok(())
3080    }
3081
3082    // -------------------------------------------------------------------
3083    // File transfer — internal handlers
3084    // -------------------------------------------------------------------
3085
3086    #[allow(clippy::too_many_arguments)]
3087    fn handle_file_offer(
3088        &self,
3089        room_id: &str,
3090        sender_fingerprint: String,
3091        file_id: String,
3092        name: String,
3093        size_bytes: u64,
3094        mime: Option<String>,
3095        _chunk_count: u32,
3096        encrypted_meta: Option<EncryptedFileMeta>,
3097    ) {
3098        let encrypted = encrypted_meta.is_some();
3099        let attachment = StoredAttachment {
3100            id: 0,
3101            room_id: room_id.to_string(),
3102            message_id: None,
3103            sender_fingerprint: sender_fingerprint.clone(),
3104            file_id: file_id.clone(),
3105            name: name.clone(),
3106            mime,
3107            size_bytes: size_bytes as i64,
3108            status: AttachmentStatus::Offered,
3109            cache_path: None,
3110            saved_path: None,
3111            error: None,
3112            encrypted,
3113            wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
3114            nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
3115            megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
3116            content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
3117            created_at: now_unix(),
3118        };
3119        if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
3120            warn!(%e, "upsert attachment");
3121            return;
3122        }
3123        // If chunks started arriving before this offer, the transfer's
3124        // size denominator was a guess — correct it with the real size.
3125        self.file_manager.set_expected_size(&file_id, size_bytes);
3126        let _ = self.app_event_tx.send(AppEvent::FileOffered {
3127            room_id: room_id.to_string(),
3128            file_id,
3129            name,
3130            size_bytes,
3131            sender_fingerprint,
3132        });
3133    }
3134
3135    fn handle_file_chunk(
3136        &self,
3137        room_id: &str,
3138        _sender_fingerprint: String,
3139        file_id: String,
3140        chunk_index: u32,
3141        total_chunks: u32,
3142        data_b64: String,
3143    ) {
3144        let data = match B64.decode(&data_b64) {
3145            Ok(d) => d,
3146            Err(e) => {
3147                warn!(%e, "bad chunk base64");
3148                return;
3149            }
3150        };
3151        // Pull the announced size + lifecycle state from our stored offer.
3152        // A terminal-state row means the user cancelled or the transfer
3153        // already failed — late chunks must not resurrect it.
3154        let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
3155            Ok(Some(a)) => {
3156                if matches!(
3157                    a.status,
3158                    AttachmentStatus::Cancelled | AttachmentStatus::Failed
3159                ) {
3160                    return;
3161                }
3162                a.size_bytes as u64
3163            }
3164            Ok(None) => crate::files::MAX_FILE_SIZE,
3165            Err(e) => {
3166                warn!(%e, "get attachment for chunk");
3167                crate::files::MAX_FILE_SIZE
3168            }
3169        };
3170
3171        let result = self.file_manager.accept_chunk(
3172            &file_id,
3173            chunk_index,
3174            total_chunks,
3175            data,
3176            expected_size,
3177        );
3178        match result {
3179            Ok(None) => {
3180                // Move offered → downloading on first chunk.
3181                let _ = repo::update_attachment_status(
3182                    &self.db,
3183                    room_id,
3184                    &file_id,
3185                    AttachmentStatus::Downloading,
3186                    None,
3187                );
3188                // Best-effort progress event — we know we've processed
3189                // (chunk_index+1)/total_chunks chunks.
3190                let bytes_so_far = self
3191                    .file_manager
3192                    .progress(&file_id)
3193                    .map(|(b, _)| b)
3194                    .unwrap_or(0);
3195                let _ = self.app_event_tx.send(AppEvent::FileProgress {
3196                    file_id: file_id.clone(),
3197                    bytes_received: bytes_so_far,
3198                    total_bytes: expected_size,
3199                });
3200            }
3201            Ok(Some(completed)) => {
3202                let _ = repo::update_attachment_paths(
3203                    &self.db,
3204                    room_id,
3205                    &file_id,
3206                    Some(&completed.cache_path.to_string_lossy()),
3207                    None,
3208                );
3209                let _ = repo::update_attachment_status(
3210                    &self.db,
3211                    room_id,
3212                    &file_id,
3213                    AttachmentStatus::Ready,
3214                    None,
3215                );
3216                let _ = self.app_event_tx.send(AppEvent::FileReady {
3217                    file_id: file_id.clone(),
3218                });
3219            }
3220            Err(e) => {
3221                let msg = e.to_string();
3222                warn!(%msg, "chunk processing failed");
3223                let _ = repo::update_attachment_status(
3224                    &self.db,
3225                    room_id,
3226                    &file_id,
3227                    AttachmentStatus::Failed,
3228                    Some(&msg),
3229                );
3230                let _ = self.app_event_tx.send(AppEvent::FileFailed {
3231                    file_id: file_id.clone(),
3232                    reason: msg,
3233                });
3234            }
3235        }
3236    }
3237
3238    /// Emit MentionReceived if `body` contains either our full
3239    /// fingerprint or its short form (first hex group).
3240    fn maybe_emit_mention(&self, room_id: &str, body: &str) {
3241        let full = self.identity.fingerprint().to_lowercase();
3242        // First hex group, e.g. "a3b1" of "a3b1-c2d4-...".
3243        let short: &str = full.split('-').next().unwrap_or(&full);
3244        let lower = body.to_lowercase();
3245        // The full fingerprint anywhere counts; the short form counts only
3246        // as a standalone hex token, so it can't match an arbitrary
3247        // substring of an unrelated hash, URL, or word.
3248        let hit = lower.contains(full.as_str())
3249            || lower
3250                .split(|c: char| !c.is_ascii_hexdigit())
3251                .any(|tok| tok == short);
3252        if hit {
3253            let _ = self.app_event_tx.send(AppEvent::MentionReceived {
3254                room_id: room_id.to_string(),
3255                body: body.to_string(),
3256            });
3257        }
3258    }
3259
3260    fn decrypt_attachment(
3261        &self,
3262        room_id: &str,
3263        sender_fingerprint: &str,
3264        ciphertext: &[u8],
3265        meta: &EncryptedFileMeta,
3266    ) -> Result<Vec<u8>> {
3267        let mut rooms = self.active_rooms.lock().unwrap();
3268        let room = rooms
3269            .get_mut(room_id)
3270            .ok_or_else(|| HuddleError::Other("not in room".into()))?;
3271        let crypto = room
3272            .crypto
3273            .as_mut()
3274            .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3275        file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
3276    }
3277}
3278
3279/// Use the platform's default opener on `path`.
3280fn open_with_system(path: &str) -> Result<()> {
3281    #[cfg(target_os = "macos")]
3282    let cmd = "open";
3283    #[cfg(target_os = "linux")]
3284    let cmd = "xdg-open";
3285    #[cfg(target_os = "windows")]
3286    let cmd = "cmd";
3287    #[cfg(target_os = "windows")]
3288    let args = vec!["/C", "start", "", path];
3289    #[cfg(not(target_os = "windows"))]
3290    let args = vec![path];
3291
3292    std::process::Command::new(cmd)
3293        .args(args)
3294        .spawn()
3295        .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
3296    Ok(())
3297}
3298
3299// Module-level salt cache: room_id -> salt. Populated when we receive
3300// announcements; queried by join_room.
3301static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
3302    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
3303
3304/// Public accessor for the Argon2id salt length used when deriving room
3305/// passphrase keys. Exists so downstream tooling (status pages, debug
3306/// CLIs, integration tests) can confirm the expected size without
3307/// re-importing the constant from `crypto::passphrase`.
3308pub fn salt_len() -> usize {
3309    SALT_LEN
3310}
3311
3312fn now_unix() -> i64 {
3313    SystemTime::now()
3314        .duration_since(UNIX_EPOCH)
3315        .unwrap()
3316        .as_secs() as i64
3317}
3318
3319/// Phase B: generate a fresh 24-char base64-ish passphrase for the
3320/// rotation that follows a kick. Sourced from `OsRng` directly so the
3321/// kicker doesn't have to think up a strong one on the spot. Returned
3322/// to the owner via the kick-result modal for OOB sharing with the
3323/// remaining members.
3324fn generate_join_passphrase() -> String {
3325    use rand::RngCore;
3326    let mut bytes = [0u8; 16];
3327    rand::thread_rng().fill_bytes(&mut bytes);
3328    // Use URL-safe-no-pad so the user can read aloud / paste without
3329    // worrying about `=` padding or `+` getting URL-escaped.
3330    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
3331}
3332
3333/// Phase F: short human-readable join code. 8 chars from a 32-symbol
3334/// alphabet (no easily-confused chars like 0/O/I/1) ≈ 40 bits — plenty
3335/// for a 10-minute online gate since the owner's client checks
3336/// exact-match (not brute-force-able offline).
3337fn generate_alphanumeric_code(len: usize) -> String {
3338    use rand::Rng;
3339    const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
3340    let mut rng = rand::thread_rng();
3341    let mut out = String::with_capacity(len + 1);
3342    for i in 0..len {
3343        if i == 4 && len == 8 {
3344            out.push('-'); // pretty: XXXX-XXXX
3345        }
3346        let idx = rng.gen_range(0..ALPHABET.len());
3347        out.push(ALPHABET[idx] as char);
3348    }
3349    out
3350}
3351
3352#[cfg(test)]
3353mod parser_tests {
3354    use super::parse_dial_address;
3355
3356    #[test]
3357    fn parses_ipv4_port() {
3358        let m = parse_dial_address("10.3.72.53:9027").unwrap();
3359        assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
3360    }
3361
3362    #[test]
3363    fn parses_bracketed_ipv6() {
3364        let m = parse_dial_address("[::1]:9027").unwrap();
3365        assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
3366    }
3367
3368    #[test]
3369    fn rejects_unbracketed_ipv6() {
3370        let err = parse_dial_address("fe80::1:9027").unwrap_err();
3371        assert!(err.to_string().contains("brackets"));
3372    }
3373
3374    #[test]
3375    fn passes_through_raw_multiaddr() {
3376        let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
3377        assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
3378    }
3379
3380    #[test]
3381    fn empty_address_is_error() {
3382        assert!(parse_dial_address("   ").is_err());
3383    }
3384
3385    #[test]
3386    fn rejects_bad_port() {
3387        assert!(parse_dial_address("1.2.3.4:notaport").is_err());
3388    }
3389}