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