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, RoomKind, 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    /// Ed25519 fingerprint learned from libp2p Identify. `None` until
41    /// the first successful connect completes. The TUI uses this to
42    /// resolve usernames + start DMs against the dialed peer.
43    pub fingerprint: Option<String>,
44}
45
46/// huddle 0.7: compute the deterministic room_id for a 1-1 DM between two
47/// fingerprints. Both peers, regardless of who calls `start_direct` first,
48/// derive identical IDs — no `created_at` mixing, no creator-fingerprint
49/// asymmetry. The pair is sorted lexicographically so the function is
50/// commutative.
51///
52/// Format: `hex(sha256("huddle-dm-v1\0" || min(a, b) || "\0" || max(a, b)))`
53/// truncated to 16 bytes (32 hex chars), matching the `derive_room_id`
54/// output length so the new DM IDs are indistinguishable from group IDs
55/// at the topic-name layer (small attacker uniformity benefit).
56pub fn canonical_dm_room_id(a: &str, b: &str) -> String {
57    use sha2::{Digest, Sha256};
58    let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
59    let mut hasher = Sha256::new();
60    hasher.update(b"huddle-dm-v1\0");
61    hasher.update(lo.as_bytes());
62    hasher.update(b"\0");
63    hasher.update(hi.as_bytes());
64    hex::encode(&hasher.finalize()[..16])
65}
66
67/// Parse a user-entered dial address into a libp2p `Multiaddr`.
68/// Accepts `ip:port`, `[ipv6]:port`, or a raw multiaddr starting with `/`.
69pub fn parse_dial_address(input: &str) -> Result<Multiaddr> {
70    let trimmed = input.trim();
71    if trimmed.is_empty() {
72        return Err(HuddleError::Other("address is empty".into()));
73    }
74    if trimmed.starts_with('/') {
75        return trimmed
76            .parse::<Multiaddr>()
77            .map_err(|e| HuddleError::Other(format!("invalid multiaddr: {e}")));
78    }
79    if let Some(rest) = trimmed.strip_prefix('[') {
80        let (host, port) = rest
81            .split_once("]:")
82            .ok_or_else(|| HuddleError::Other(format!("expected [ipv6]:port, got {trimmed}")))?;
83        let port: u16 = port
84            .parse()
85            .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
86        return format!("/ip6/{}/tcp/{}", host, port)
87            .parse::<Multiaddr>()
88            .map_err(|e| HuddleError::Other(format!("invalid ipv6 address: {e}")));
89    }
90    let (host, port) = trimmed
91        .rsplit_once(':')
92        .ok_or_else(|| HuddleError::Other(format!("expected ip:port, got {trimmed}")))?;
93    if host.contains(':') {
94        return Err(HuddleError::Other(format!(
95            "ambiguous IPv6 address — wrap host in brackets: [{host}]:{port}"
96        )));
97    }
98    let port: u16 = port
99        .parse()
100        .map_err(|_| HuddleError::Other(format!("invalid port: {port}")))?;
101    format!("/ip4/{}/tcp/{}", host, port)
102        .parse::<Multiaddr>()
103        .map_err(|e| HuddleError::Other(format!("invalid address: {e}")))
104}
105
106/// State for a room we've created or joined this session.
107struct ActiveRoom {
108    info: StoredRoom,
109    crypto: Option<RoomCrypto>,
110    /// Argon2id-derived 32-byte key for unwrapping incoming session keys.
111    /// None for unencrypted rooms.
112    passphrase_key: Option<[u8; KEY_LEN]>,
113    /// Fingerprints of members currently known to be in the room.
114    members: HashSet<String>,
115    /// Ephemeral typing indicators: fingerprint → unix expiry. Pruned
116    /// on read; never persisted.
117    typers: HashMap<String, i64>,
118    /// Phase F: we joined via a short-lived code rather than the
119    /// passphrase. We have other members' session keys (delivered via
120    /// the CodeJoinResponse ECDH handshake) so we can decrypt; but
121    /// without the passphrase we can't wrap our own outbound session
122    /// key for other members. Read-only until an owner re-onboards us
123    /// with the full passphrase. Defaults false for passphrase joins.
124    read_only: bool,
125    /// Phase F: owner-issued join codes for this room (owner side
126    /// only). Pairs of (code, expires_at_unix). Single-use; entries
127    /// removed after a successful CodeJoinResponse goes out.
128    issued_codes: Vec<(String, i64)>,
129}
130
131const TYPING_TTL_SECS: i64 = 3;
132
133/// TTL for a discovered room before it's considered stale (re-announcements
134/// happen every 15 seconds; after 45s of silence we drop it).
135const DISCOVERED_TTL_SECS: i64 = 45;
136const ANNOUNCE_INTERVAL_SECS: u64 = 15;
137
138/// Phase G: in-flight SAS verification state, keyed by tx_id. Held in
139/// memory only; survives just long enough for the two-message
140/// handshake + the user pressing Match on both sides.
141struct SasFlow {
142    room_id: String,
143    partner_fingerprint: String,
144    our_secret: x25519_dalek::StaticSecret,
145    /// Set once we know both sides' pubkeys → the derived SAS code.
146    sas_code: Option<crate::crypto::sas::SasCode>,
147    our_confirmed: bool,
148    their_confirmed: bool,
149}
150
151#[derive(Clone)]
152pub struct AppHandle {
153    identity: Arc<Identity>,
154    network: NetworkHandle,
155    mode: NetworkMode,
156    active_rooms: Arc<Mutex<HashMap<String, ActiveRoom>>>,
157    discovered_rooms: Arc<Mutex<HashMap<String, DiscoveredRoom>>>,
158    /// Encrypted rooms loaded from storage that we haven't rejoined yet
159    /// in this session (their passphrase-derived key isn't in memory).
160    /// Surfaced in the lobby so the user can re-enter with passphrase.
161    restorable_rooms: Arc<Mutex<HashMap<String, StoredRoom>>>,
162    /// Peer addresses we've dialed in this process; tracks "is the
163    /// connection currently up" for known peers shown in the lobby.
164    connected_dial_addrs: Arc<Mutex<HashMap<String, PeerId>>>,
165    /// File chunking + cache + downloads.
166    file_manager: Arc<FileManager>,
167    db: Db,
168    /// 32-byte key Megolm session pickles are encrypted under at rest —
169    /// an HKDF subkey of the master key, or all-zero on the
170    /// `--no-master-passphrase` / unencrypted-DB path.
171    session_persist_key: [u8; 32],
172    /// Phase G: active SAS verifications. Keyed by tx_id (the random
173    /// 16-byte salt picked by the initiator + base64'd).
174    sas_flows: Arc<Mutex<HashMap<String, SasFlow>>>,
175    /// Phase F: ephemeral X25519 secrets the joiner is holding while
176    /// they wait for the owner's `CodeJoinResponse`. Keyed by
177    /// `(room_id, joiner_fp)` so multiple joiners in the same room can
178    /// be in flight concurrently without trampling each other; and so
179    /// the 30s timeout task (see `join_room_with_code`) can clean up
180    /// its own entry by composite key without racing with peers.
181    pending_code_secrets:
182        Arc<Mutex<HashMap<(String, String), x25519_dalek::StaticSecret>>>,
183    /// Phase C follow-up: tracks "we dialed this multiaddr because of
184    /// an invite link claiming this fingerprint." When the peer
185    /// identifies (and we can derive their real fp), the post-dial arm
186    /// looks the multiaddr up here and compares — if the claimed and
187    /// derived fingerprints don't match, we disconnect and surface
188    /// an `InviteFingerprintMismatch` event.
189    ///
190    /// libp2p's `/p2p/<peer-id>` segment already enforces this at the
191    /// transport level when present (and our invite generator always
192    /// includes it), so this is defense in depth — but it also makes
193    /// the assert explicit so future invite-format changes can't slip
194    /// in a forgeable fingerprint label.
195    pending_invite_dials: Arc<Mutex<HashMap<String, String>>>,
196    /// Phase D follow-up: addresses confirmed reachable by AutoNAT v2
197    /// probes. We emit a `NatStatusChanged` whenever this set
198    /// transitions between empty (private / undetected) and
199    /// non-empty (reachable), so the TUI badge doesn't flap on every
200    /// individual probe.
201    nat_reachable_addrs: Arc<Mutex<HashSet<String>>>,
202    /// Phase D follow-up: `/p2p-circuit` reservation addresses we've
203    /// established via configured relays. These are populated when
204    /// `RelayReservationEstablished` arrives and feed into the
205    /// `RoomAnnouncement.host_addrs` field so cross-internet peers
206    /// can bootstrap without an invite link.
207    relay_circuit_addrs: Arc<Mutex<HashSet<String>>>,
208    /// Phase D follow-up: per-creator-fingerprint last-dial timestamp.
209    /// Throttles the opportunistic dial we issue when an announcement
210    /// arrives carrying `host_addrs` — we re-dial the same announcer
211    /// at most once per `HOST_ADDR_DIAL_BACKOFF_SECS`.
212    host_addr_dial_attempts: Arc<Mutex<HashMap<String, i64>>>,
213    /// huddle 0.5: per-peer last-broadcast timestamp (ms) for our own
214    /// `ProfileUpdate`. The `PeerIdentified` handler re-broadcasts our
215    /// current username to a newly-identified peer so they learn it
216    /// without waiting for a change, but we dedupe with a
217    /// `PROFILE_REBROADCAST_FLOOR_MS` floor so a noisy reconnect cycle
218    /// doesn't spam the gossipsub mesh.
219    last_profile_broadcast_at_ms: Arc<Mutex<HashMap<String, i64>>>,
220    /// huddle 0.7.7: addresses the local user just initiated a dial on
221    /// (`d` / `a` / paste-invite). When `PeerIdentified` lands for one
222    /// of these, we open (or reuse) a DM with the identified peer and
223    /// emit `AutoOpenDm` so the TUI can switch into the new pane. The
224    /// set is consumed on use, so a passive auto-reconnect or an
225    /// inbound dial never triggers the auto-DM.
226    pending_auto_dm_addrs: Arc<Mutex<HashSet<String>>>,
227    app_event_tx: broadcast::Sender<AppEvent>,
228}
229
230/// Phase D follow-up: minimum seconds between two opportunistic
231/// `host_addrs` dials to the same announcer fingerprint.
232const HOST_ADDR_DIAL_BACKOFF_SECS: i64 = 300;
233
234/// huddle 0.5: minimum ms between two `PeerIdentified`-triggered
235/// re-broadcasts of our own `ProfileUpdate` to the same peer
236/// fingerprint. Prevents storm-on-reconnect on flaky transports.
237const PROFILE_REBROADCAST_FLOOR_MS: i64 = 60_000;
238
239impl AppHandle {
240    pub async fn start() -> Result<Self> {
241        Self::start_with_options(NetworkMode::Mdns, 0, None, Vec::new()).await
242    }
243
244    pub async fn start_with_options(
245        mode: NetworkMode,
246        port: u16,
247        master_key: Option<&[u8; 32]>,
248        relays: Vec<Multiaddr>,
249    ) -> Result<Self> {
250        config::ensure_data_dir()?;
251        // Megolm session state is encrypted at rest with an HKDF subkey
252        // of the master key. With no master key (--no-master-passphrase /
253        // tests) it's persisted under the all-zero key, matching the
254        // unencrypted-DB story.
255        let session_persist_key = match master_key {
256            Some(mk) => storage::keychain::derive_subkey(mk, b"megolm-persist"),
257            None => [0u8; 32],
258        };
259        let db = storage::open_db(&config::db_path(), master_key)?;
260        Self::start_with_db_and_options(db, mode, port, session_persist_key, relays).await
261    }
262
263    pub async fn start_with_db(db: Db) -> Result<Self> {
264        Self::start_with_db_and_options(db, NetworkMode::Mdns, 0, [0u8; 32], Vec::new()).await
265    }
266
267    pub async fn start_with_db_and_options(
268        db: Db,
269        mode: NetworkMode,
270        port: u16,
271        session_persist_key: [u8; 32],
272        relays: Vec<Multiaddr>,
273    ) -> Result<Self> {
274        let identity = Self::load_or_create_identity(&db)?;
275        let identity = Arc::new(identity);
276        info!(fingerprint = %identity.fingerprint(), peer_id = %identity.peer_id(), mode = %mode.as_str(), port, relay_count = relays.len(), "identity loaded");
277
278        let (net_event_tx, net_event_rx) = tokio::sync::mpsc::channel::<NetworkEvent>(256);
279        let (app_event_tx, _) = broadcast::channel::<AppEvent>(256);
280        let network =
281            network::start_network_with(&identity, net_event_tx, mode, port, relays)?;
282
283        let active_rooms = Arc::new(Mutex::new(HashMap::new()));
284        let discovered_rooms = Arc::new(Mutex::new(HashMap::new()));
285        let restorable_rooms = Arc::new(Mutex::new(HashMap::new()));
286        let connected_dial_addrs = Arc::new(Mutex::new(HashMap::new()));
287        let file_manager = Arc::new(FileManager::new(&config::data_dir())?);
288
289        let handle = Self {
290            identity,
291            network,
292            mode,
293            active_rooms,
294            discovered_rooms,
295            restorable_rooms,
296            connected_dial_addrs,
297            file_manager,
298            db,
299            session_persist_key,
300            sas_flows: Arc::new(Mutex::new(HashMap::new())),
301            pending_code_secrets: Arc::new(Mutex::new(HashMap::new())),
302            pending_invite_dials: Arc::new(Mutex::new(HashMap::new())),
303            nat_reachable_addrs: Arc::new(Mutex::new(HashSet::new())),
304            relay_circuit_addrs: Arc::new(Mutex::new(HashSet::new())),
305            host_addr_dial_attempts: Arc::new(Mutex::new(HashMap::new())),
306            last_profile_broadcast_at_ms: Arc::new(Mutex::new(HashMap::new())),
307            pending_auto_dm_addrs: Arc::new(Mutex::new(HashSet::new())),
308            app_event_tx,
309        };
310
311        handle.spawn_event_processor(net_event_rx);
312        handle.spawn_announcement_ticker();
313        handle.spawn_discovered_room_pruner();
314        handle.spawn_known_peer_reconnector();
315        handle.restore_rooms_from_db().await;
316        // huddle 0.7.7: prune any friend requests that aged out while
317        // we were offline. Best-effort — a DB failure here shouldn't
318        // block startup, so we log and move on.
319        if let Err(e) = repo::cleanup_expired_pending_friend_requests(&handle.db, now_unix()) {
320            warn!(%e, "failed to sweep expired pending friend requests");
321        }
322
323        Ok(handle)
324    }
325
326    pub fn mode(&self) -> NetworkMode {
327        self.mode
328    }
329
330    pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
331        self.app_event_tx.subscribe()
332    }
333
334    pub fn fingerprint(&self) -> &str {
335        self.identity.fingerprint()
336    }
337
338    pub fn peer_id(&self) -> PeerId {
339        self.identity.peer_id()
340    }
341
342    pub fn discovered_rooms(&self) -> Vec<DiscoveredRoom> {
343        let now = now_unix();
344        let our_fp = self.identity.fingerprint().to_string();
345        let mut by_id: HashMap<String, DiscoveredRoom> = self
346            .discovered_rooms
347            .lock()
348            .unwrap()
349            .clone();
350
351        // Merge in rooms we're currently in — gossipsub doesn't echo our
352        // own announcements back to us, so without this our own hosted
353        // rooms wouldn't appear in the lobby.
354        for room in self.active_rooms.lock().unwrap().values() {
355            let entry = DiscoveredRoom {
356                room_id: room.info.id.clone(),
357                name: room.info.name.clone(),
358                encrypted: room.info.encrypted,
359                member_count: room.members.len() as u32,
360                creator_fingerprint: room.info.creator_fingerprint.clone(),
361                last_seen: now,
362                restorable: false,
363                host_addrs: Vec::new(),
364                kind: room.info.kind,
365            };
366            by_id
367                .entry(room.info.id.clone())
368                .and_modify(|d| {
369                    d.last_seen = now;
370                    if entry.member_count > d.member_count {
371                        d.member_count = entry.member_count;
372                    }
373                    d.restorable = false;
374                    d.kind = entry.kind;
375                })
376                .or_insert(entry);
377        }
378
379        // Encrypted rooms we have on disk but haven't rejoined this
380        // session. Only surface them when no fresh discovery / active
381        // entry exists for the same room.
382        for (id, stored) in self.restorable_rooms.lock().unwrap().iter() {
383            if by_id.contains_key(id) {
384                continue;
385            }
386            by_id.insert(
387                id.clone(),
388                DiscoveredRoom {
389                    room_id: id.clone(),
390                    name: stored.name.clone(),
391                    encrypted: stored.encrypted,
392                    member_count: 0,
393                    creator_fingerprint: stored.creator_fingerprint.clone(),
394                    last_seen: stored.last_active.unwrap_or(stored.created_at),
395                    restorable: true,
396                    host_addrs: Vec::new(),
397                    kind: stored.kind,
398                },
399            );
400        }
401
402        // huddle 0.7 DM-visibility filter: drop any `Direct` room we're
403        // not a member of. A DM's canonical room_id is
404        // `canonical_dm_room_id(fp_a, fp_b)`. If we're one of the pair we
405        // pass; otherwise we drop. Honest 0.7+ peers enforce this at the
406        // consumer; combined with the canonical-ID scheme it keeps DMs
407        // out of any third party's sidebar even if they happen to relay
408        // the gossipsub announcement.
409        by_id.retain(|room_id, d| {
410            if d.kind != RoomKind::Direct {
411                return true;
412            }
413            // Active rooms we host pass unconditionally — we always know
414            // we're a member of our own DM.
415            if self
416                .active_rooms
417                .lock()
418                .unwrap()
419                .contains_key(room_id)
420            {
421                return true;
422            }
423            // Otherwise: the announcer must be the other partner, AND
424            // the canonical pair must include us.
425            canonical_dm_room_id(&our_fp, &d.creator_fingerprint) == *room_id
426        });
427
428        let mut v: Vec<DiscoveredRoom> = by_id.into_values().collect();
429        v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
430        v
431    }
432
433    /// huddle 0.7: returns the fingerprint of the other party in a 1-1
434    /// DM. `None` for rooms that are `Group`, missing, or somehow have a
435    /// non-2-member state. Used by the DM-pane header to render the
436    /// partner's username + HD-ID.
437    pub fn dm_partner_fingerprint(&self, room_id: &str) -> Option<String> {
438        let our_fp = self.identity.fingerprint().to_string();
439        let rooms = self.active_rooms.lock().unwrap();
440        let room = rooms.get(room_id)?;
441        if room.info.kind != RoomKind::Direct {
442            return None;
443        }
444        room.members
445            .iter()
446            .find(|m| **m != our_fp)
447            .cloned()
448    }
449
450    pub fn active_room_ids(&self) -> Vec<String> {
451        self.active_rooms.lock().unwrap().keys().cloned().collect()
452    }
453
454    pub fn active_room_info(&self, room_id: &str) -> Option<StoredRoom> {
455        self.active_rooms
456            .lock()
457            .unwrap()
458            .get(room_id)
459            .map(|r| r.info.clone())
460    }
461
462    pub fn room_members(&self, room_id: &str) -> Vec<String> {
463        self.active_rooms
464            .lock()
465            .unwrap()
466            .get(room_id)
467            .map(|r| {
468                let mut m: Vec<String> = r.members.iter().cloned().collect();
469                m.sort();
470                m
471            })
472            .unwrap_or_default()
473    }
474
475    pub fn room_messages(&self, room_id: &str, limit: i64) -> Result<Vec<repo::StoredRoomMessage>> {
476        repo::get_room_messages(&self.db, room_id, limit)
477    }
478
479    pub fn search_room_messages(
480        &self,
481        room_id: &str,
482        query: &str,
483        limit: i64,
484    ) -> Result<Vec<repo::StoredRoomMessage>> {
485        repo::search_room_messages(&self.db, room_id, query, limit)
486    }
487
488    /// Create a new room. Returns its room_id.
489    ///
490    /// huddle 0.7: `kind` is now required. `RoomKind::Group` (the default)
491    /// preserves pre-0.7 behavior. `RoomKind::Direct` is reserved for
492    /// callers that have already computed a deterministic DM room_id via
493    /// `canonical_dm_room_id` — most clients should call `start_direct`
494    /// instead, which handles idempotency, kind, and naming.
495    pub async fn start_room(
496        &self,
497        name: &str,
498        encrypted: bool,
499        passphrase: Option<&str>,
500        kind: RoomKind,
501    ) -> Result<String> {
502        if encrypted && passphrase.is_none() {
503            return Err(HuddleError::Other(
504                "encrypted room requires a passphrase".into(),
505            ));
506        }
507
508        let created_at = now_unix();
509        let creator_fp = self.identity.fingerprint().to_string();
510        let room_id = derive_room_id(&creator_fp, name, created_at);
511
512        let (passphrase_salt, passphrase_key) = if encrypted {
513            let salt = passphrase::random_salt();
514            let key = passphrase::derive_key(passphrase.unwrap(), &salt)?;
515            (Some(salt.to_vec()), Some(key))
516        } else {
517            (None, None)
518        };
519
520        let info = StoredRoom {
521            id: room_id.clone(),
522            name: name.to_string(),
523            creator_fingerprint: creator_fp.clone(),
524            encrypted,
525            passphrase_salt: passphrase_salt.clone(),
526            created_at,
527            last_active: Some(created_at),
528            kind,
529        };
530        repo::insert_room(&self.db, &info)?;
531
532        let crypto = if encrypted {
533            Some(RoomCrypto::new_for_room(
534                self.db.clone(),
535                room_id.clone(),
536                creator_fp.clone(),
537                self.session_persist_key,
538            )?)
539        } else {
540            None
541        };
542
543        let mut members = HashSet::new();
544        members.insert(creator_fp.clone());
545
546        // Phase B: the room creator is the first owner. Persisted now so
547        // the very first announcement includes our fingerprint in
548        // `owner_fingerprints`, letting joiners know who's authorized.
549        repo::upsert_room_member(
550            &self.db,
551            &StoredRoomMember {
552                room_id: room_id.clone(),
553                peer_id: String::new(),
554                fingerprint: creator_fp.clone(),
555                last_seen: Some(created_at),
556                verified: true, // we trust ourselves
557                ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
558                role: "owner".into(),
559            },
560        )?;
561
562        self.active_rooms.lock().unwrap().insert(
563            room_id.clone(),
564            ActiveRoom {
565                info: info.clone(),
566                crypto,
567                passphrase_key,
568                members,
569                typers: HashMap::new(),
570                read_only: false,
571                issued_codes: Vec::new(),
572            },
573        );
574
575        self.network.subscribe_room(room_id.clone()).await;
576        self.announce_room_now(&info, 1).await;
577
578        // Broadcast our presence in the room (with our wrapped session key
579        // if encrypted). Use a small delay so the subscription propagates.
580        let app = self.clone();
581        let rid = room_id.clone();
582        tokio::spawn(async move {
583            tokio::time::sleep(Duration::from_millis(500)).await;
584            if let Err(e) = app.broadcast_member_announce(&rid).await {
585                warn!(%e, "broadcast member announce");
586            }
587        });
588
589        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
590            room_id: room_id.clone(),
591        });
592
593        Ok(room_id)
594    }
595
596    /// huddle 0.7.1: start (or open) a 1-1 DM with `partner_fingerprint`.
597    ///
598    /// Idempotent across peers and reopens:
599    /// 1. Refuses to DM yourself.
600    /// 2. Computes `room_id = canonical_dm_room_id(our_fp, partner_fp)`.
601    ///    Both peers, regardless of who clicks first, derive identical
602    ///    IDs.
603    /// 3. If a DM room already exists locally (active or stored), returns
604    ///    its id — no new room, no second announcement.
605    /// 4. Otherwise creates a `RoomKind::Direct`, **end-to-end encrypted**
606    ///    room. The key is derived from Ed25519→X25519 ECDH between the
607    ///    two parties' identity keys (see `crypto::dm::derive_dm_key`).
608    ///    No shared passphrase, no central key agreement — both peers
609    ///    independently derive the same 32-byte room key from their
610    ///    own seed + the other's pubkey.
611    /// 5. If we don't yet know the partner's Ed25519 pubkey, the room
612    ///    is still created encrypted; the key is derived lazily once
613    ///    `MemberAnnounce` arrives with the partner's pubkey, after
614    ///    which we send our wrapped Megolm session key in a follow-up
615    ///    announce.
616    /// 6. Subscribes to the room topic and announces on the global topic.
617    ///    The announcement is visibility-filtered at honest 0.7+ peers,
618    ///    so only the partner sees it in their `discovered_rooms()`.
619    pub async fn start_direct(&self, partner_fingerprint: &str) -> Result<String> {
620        let our_fp = self.identity.fingerprint().to_string();
621        if partner_fingerprint == our_fp {
622            return Err(HuddleError::Other("cannot DM yourself".into()));
623        }
624        let room_id = canonical_dm_room_id(&our_fp, partner_fingerprint);
625
626        // Idempotent reopen: if the room already exists on disk or in
627        // memory, surface its id without creating a duplicate. This
628        // handles both "I already DM'd them" and "they DM'd me first
629        // and we auto-accepted" paths.
630        if self.active_rooms.lock().unwrap().contains_key(&room_id) {
631            let _ = self.app_event_tx.send(AppEvent::RoomJoined {
632                room_id: room_id.clone(),
633            });
634            return Ok(room_id);
635        }
636        if repo::get_room(&self.db, &room_id)?.is_some() {
637            // Re-bootstrap the in-memory active room from disk.
638            return self.bootstrap_direct_room(&room_id, partner_fingerprint).await;
639        }
640
641        let created_at = now_unix();
642        // The name is internal/derived — the DM pane renders the partner
643        // username + HD-ID instead. Including the short fp keeps the row
644        // navigable in `sqlite3` if someone digs into the DB.
645        let name = format!("dm-{}", short_fp_for_msg(partner_fingerprint));
646
647        // huddle 0.7.1: DMs are always encrypted. The salt slot stores
648        // the canonical room_id (16 raw bytes from the SHA-256 prefix)
649        // so a re-bootstrap can re-derive the same key. The actual key
650        // comes from ECDH below, not from this salt — but we keep the
651        // salt slot non-NULL so legacy code paths (which assume
652        // encrypted rooms have salts) don't choke.
653        let dm_salt = hex::decode(&room_id).unwrap_or_else(|_| room_id.as_bytes().to_vec());
654        let info = StoredRoom {
655            id: room_id.clone(),
656            name,
657            creator_fingerprint: our_fp.clone(),
658            encrypted: true,
659            passphrase_salt: Some(dm_salt),
660            created_at,
661            last_active: Some(created_at),
662            kind: RoomKind::Direct,
663        };
664        repo::insert_room(&self.db, &info)?;
665
666        let mut members = HashSet::new();
667        members.insert(our_fp.clone());
668        repo::upsert_room_member(
669            &self.db,
670            &StoredRoomMember {
671                room_id: room_id.clone(),
672                peer_id: String::new(),
673                fingerprint: our_fp.clone(),
674                last_seen: Some(created_at),
675                verified: true,
676                ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
677                role: "member".into(),
678            },
679        )?;
680
681        // Try to derive the ECDH key now. If the partner's pubkey
682        // hasn't been observed yet (we know their fingerprint from a
683        // QR / invite / username lookup, but never seen a signed
684        // message from them), the key is None and gets populated by
685        // the `MemberAnnounce` handler below the moment partner's
686        // first announcement lands.
687        let passphrase_key = self.try_derive_dm_key(&room_id, partner_fingerprint);
688
689        // Always create our outbound Megolm session so we can encrypt
690        // *something* the moment the key materializes. RoomCrypto
691        // works the same as it does for group rooms — the only
692        // difference is where `passphrase_key` comes from.
693        let crypto = Some(RoomCrypto::new_for_room(
694            self.db.clone(),
695            room_id.clone(),
696            our_fp.clone(),
697            self.session_persist_key,
698        )?);
699
700        self.active_rooms.lock().unwrap().insert(
701            room_id.clone(),
702            ActiveRoom {
703                info: info.clone(),
704                crypto,
705                passphrase_key,
706                members,
707                typers: HashMap::new(),
708                read_only: false,
709                issued_codes: Vec::new(),
710            },
711        );
712
713        self.network.subscribe_room(room_id.clone()).await;
714        self.announce_room_now(&info, 1).await;
715
716        let app = self.clone();
717        let rid = room_id.clone();
718        tokio::spawn(async move {
719            tokio::time::sleep(Duration::from_millis(500)).await;
720            if let Err(e) = app.broadcast_member_announce(&rid).await {
721                warn!(%e, "broadcast member announce for DM");
722            }
723        });
724
725        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
726            room_id: room_id.clone(),
727        });
728        Ok(room_id)
729    }
730
731    /// huddle 0.7.1: derive a DM key from a base64-encoded partner
732    /// pubkey. Mirrors `try_derive_dm_key` but operates on a pubkey we
733    /// just received (e.g. via `MemberAnnounce.sender_ed25519_pubkey`)
734    /// without re-querying the DB.
735    fn derive_dm_key_from_pubkey_b64(
736        &self,
737        room_id: &str,
738        pubkey_b64: &str,
739    ) -> Option<[u8; KEY_LEN]> {
740        let bytes = B64.decode(pubkey_b64).ok()?;
741        if bytes.len() != 32 {
742            return None;
743        }
744        let mut pubkey = [0u8; 32];
745        pubkey.copy_from_slice(&bytes);
746        let our_seed = self.identity.secret_bytes();
747        match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
748            Ok(k) => Some(k),
749            Err(e) => {
750                warn!(%e, "DM key derivation (from announce) failed");
751                None
752            }
753        }
754    }
755
756    /// huddle 0.7.1: look up partner's Ed25519 pubkey (from anywhere
757    /// we've persisted it) and derive the DM room key via ECDH. Returns
758    /// `None` when the pubkey isn't known yet — the caller proceeds
759    /// without a key and the `MemberAnnounce` handler retries later.
760    fn try_derive_dm_key(
761        &self,
762        room_id: &str,
763        partner_fingerprint: &str,
764    ) -> Option<[u8; KEY_LEN]> {
765        let pubkey_b64 = repo::lookup_peer_ed25519_pubkey(&self.db, partner_fingerprint)
766            .ok()
767            .flatten()?;
768        let bytes = B64.decode(&pubkey_b64).ok()?;
769        if bytes.len() != 32 {
770            return None;
771        }
772        let mut pubkey = [0u8; 32];
773        pubkey.copy_from_slice(&bytes);
774        let our_seed = self.identity.secret_bytes();
775        match crate::crypto::dm::derive_dm_key(&our_seed, &pubkey, room_id) {
776            Ok(k) => Some(k),
777            Err(e) => {
778                warn!(%e, %partner_fingerprint, "DM key derivation failed");
779                None
780            }
781        }
782    }
783
784    /// Internal: re-hydrate an existing on-disk DM room into
785    /// `active_rooms` and re-subscribe / re-announce. Used by
786    /// `start_direct` when the room exists on disk but not in memory
787    /// (e.g. process restart) and by the auto-accept path when a DM
788    /// announcement arrives from the partner.
789    async fn bootstrap_direct_room(
790        &self,
791        room_id: &str,
792        partner_fingerprint: &str,
793    ) -> Result<String> {
794        let our_fp = self.identity.fingerprint().to_string();
795        let info = repo::get_room(&self.db, room_id)?
796            .ok_or_else(|| HuddleError::Other(format!("DM room {room_id} not found on disk")))?;
797        let mut members = HashSet::new();
798        members.insert(our_fp.clone());
799        members.insert(partner_fingerprint.to_string());
800
801        // Pull persisted members so re-bootstrap doesn't lose them.
802        if let Ok(stored_members) = repo::list_room_members(&self.db, room_id) {
803            for m in stored_members {
804                members.insert(m.fingerprint);
805            }
806        }
807
808        // huddle 0.7.1: rehydrate the ECDH key + Megolm session if the
809        // partner's pubkey is on disk (which it always is after at
810        // least one previous MemberAnnounce). For older DMs that
811        // pre-date 0.7.1 (when DMs were unencrypted on the room
812        // layer), `info.encrypted` is false — preserve that and skip
813        // the ECDH derivation; the room continues operating as it did
814        // before. New 0.7.1+ DMs all have `encrypted = true`.
815        let (passphrase_key, crypto) = if info.encrypted {
816            let pk = self.try_derive_dm_key(room_id, partner_fingerprint);
817            let c = Some(RoomCrypto::load(
818                self.db.clone(),
819                room_id.to_string(),
820                our_fp.clone(),
821                self.session_persist_key,
822            )?
823            .unwrap_or_else(|| {
824                RoomCrypto::new_for_room(
825                    self.db.clone(),
826                    room_id.to_string(),
827                    our_fp.clone(),
828                    self.session_persist_key,
829                )
830                .expect("create RoomCrypto for DM re-bootstrap")
831            }));
832            (pk, c)
833        } else {
834            (None, None)
835        };
836
837        self.active_rooms.lock().unwrap().insert(
838            room_id.to_string(),
839            ActiveRoom {
840                info: info.clone(),
841                crypto,
842                passphrase_key,
843                members,
844                typers: HashMap::new(),
845                read_only: false,
846                issued_codes: Vec::new(),
847            },
848        );
849
850        self.network.subscribe_room(room_id.to_string()).await;
851        self.announce_room_now(&info, 2).await;
852
853        let app = self.clone();
854        let rid = room_id.to_string();
855        tokio::spawn(async move {
856            tokio::time::sleep(Duration::from_millis(500)).await;
857            if let Err(e) = app.broadcast_member_announce(&rid).await {
858                warn!(%e, "broadcast member announce on DM bootstrap");
859            }
860        });
861
862        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
863            room_id: room_id.to_string(),
864        });
865        Ok(room_id.to_string())
866    }
867
868    /// Join an existing room. The room may come from a live announcement
869    /// (preferred), our restorable set, or the DB directly — whichever has
870    /// the freshest copy. For encrypted rooms `passphrase` is required.
871    pub async fn join_room(&self, room_id: &str, passphrase: Option<&str>) -> Result<()> {
872        // Resolve room metadata from the freshest available source.
873        let (name, creator_fingerprint, encrypted, salt_opt) = {
874            if let Some(d) = self.discovered_rooms.lock().unwrap().get(room_id).cloned() {
875                let salt = self.get_room_salt(room_id);
876                (d.name, d.creator_fingerprint, d.encrypted, salt)
877            } else if let Some(stored) = self.restorable_rooms.lock().unwrap().get(room_id).cloned()
878            {
879                (
880                    stored.name,
881                    stored.creator_fingerprint,
882                    stored.encrypted,
883                    stored.passphrase_salt,
884                )
885            } else if let Some(stored) = repo::get_room(&self.db, room_id)? {
886                (
887                    stored.name,
888                    stored.creator_fingerprint,
889                    stored.encrypted,
890                    stored.passphrase_salt,
891                )
892            } else {
893                return Err(HuddleError::Other(format!("room {room_id} not found")));
894            }
895        };
896
897        if encrypted && passphrase.is_none() {
898            return Err(HuddleError::Other(
899                "encrypted room requires a passphrase".into(),
900            ));
901        }
902
903        let passphrase_key = if encrypted {
904            let salt = salt_opt
905                .clone()
906                .ok_or_else(|| HuddleError::Other("missing salt for encrypted room".into()))?;
907            Some(passphrase::derive_key(passphrase.unwrap(), &salt)?)
908        } else {
909            None
910        };
911
912        // huddle 0.7: preserve the kind that came from the announcement
913        // / restorable cache / DB. If we don't have it (very old row),
914        // default to Group — matches the schema column default and the
915        // back-fill policy.
916        let kind = self
917            .discovered_rooms
918            .lock()
919            .unwrap()
920            .get(room_id)
921            .map(|d| d.kind)
922            .or_else(|| {
923                repo::get_room(&self.db, room_id)
924                    .ok()
925                    .flatten()
926                    .map(|r| r.kind)
927            })
928            .unwrap_or_default();
929
930        let info = StoredRoom {
931            id: room_id.to_string(),
932            name,
933            creator_fingerprint,
934            encrypted,
935            passphrase_salt: salt_opt.clone(),
936            created_at: now_unix(),
937            last_active: Some(now_unix()),
938            kind,
939        };
940        repo::insert_room(&self.db, &info)?;
941
942        let crypto = if encrypted {
943            // Reuse persisted Megolm sessions on re-join; only mint a fresh
944            // outbound session when nothing is stored for this room yet.
945            let our_fp = self.identity.fingerprint().to_string();
946            let existing = RoomCrypto::load(
947                self.db.clone(),
948                room_id.to_string(),
949                our_fp.clone(),
950                self.session_persist_key,
951            )?;
952            Some(match existing {
953                Some(c) => c,
954                None => RoomCrypto::new_for_room(
955                    self.db.clone(),
956                    room_id.to_string(),
957                    our_fp,
958                    self.session_persist_key,
959                )?,
960            })
961        } else {
962            None
963        };
964
965        let mut members = HashSet::new();
966        members.insert(self.identity.fingerprint().to_string());
967
968        self.active_rooms.lock().unwrap().insert(
969            room_id.to_string(),
970            ActiveRoom {
971                info: info.clone(),
972                crypto,
973                passphrase_key,
974                members,
975                typers: HashMap::new(),
976                read_only: false,
977                issued_codes: Vec::new(),
978            },
979        );
980        // No longer "restorable" now that we've rejoined.
981        self.restorable_rooms.lock().unwrap().remove(room_id);
982
983        self.network.subscribe_room(room_id.to_string()).await;
984
985        let app = self.clone();
986        let rid = room_id.to_string();
987        tokio::spawn(async move {
988            tokio::time::sleep(Duration::from_millis(500)).await;
989            if let Err(e) = app.broadcast_member_announce(&rid).await {
990                warn!(%e, "broadcast member announce");
991            }
992            // Ask existing members for their session keys.
993            let req = RoomMessage::SessionKeyRequest {
994                requester_fingerprint: app.identity.fingerprint().to_string(),
995            };
996            if let Ok(bytes) = encode_wire(&req) {
997                app.network.publish_room_message(rid.clone(), bytes).await;
998            }
999        });
1000
1001        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
1002            room_id: room_id.to_string(),
1003        });
1004
1005        Ok(())
1006    }
1007
1008    /// Walk the rooms table at startup. Non-encrypted rooms are silently
1009    /// restored (subscribed + re-announced). Encrypted rooms get added to
1010    /// `restorable_rooms` so the lobby surfaces them and the user can
1011    /// re-enter via the join flow with passphrase.
1012    async fn restore_rooms_from_db(&self) {
1013        let rooms = match repo::list_rooms(&self.db) {
1014            Ok(v) => v,
1015            Err(e) => {
1016                warn!(%e, "list rooms on restore");
1017                return;
1018            }
1019        };
1020        let our_fp = self.identity.fingerprint().to_string();
1021        let count = rooms.len();
1022        for info in rooms {
1023            if info.encrypted {
1024                self.restorable_rooms
1025                    .lock()
1026                    .unwrap()
1027                    .insert(info.id.clone(), info);
1028                continue;
1029            }
1030            let mut members = HashSet::new();
1031            members.insert(our_fp.clone());
1032            if let Ok(stored_members) = repo::list_room_members(&self.db, &info.id) {
1033                for m in stored_members {
1034                    members.insert(m.fingerprint);
1035                }
1036            }
1037            let member_count = members.len() as u32;
1038            self.active_rooms.lock().unwrap().insert(
1039                info.id.clone(),
1040                ActiveRoom {
1041                    info: info.clone(),
1042                    crypto: None,
1043                    passphrase_key: None,
1044                    members,
1045                    typers: HashMap::new(),
1046                    read_only: false,
1047                    issued_codes: Vec::new(),
1048                },
1049            );
1050            self.network.subscribe_room(info.id.clone()).await;
1051            self.announce_room_now(&info, member_count).await;
1052            info!(room_id = %info.id, name = %info.name, "restored room");
1053        }
1054        if count > 0 {
1055            debug!(count, "restored rooms from db");
1056        }
1057    }
1058
1059    /// Leave a room. Returns `true` when the `MemberLeave` notice was
1060    /// handed to the network layer, `false` when it couldn't be encoded
1061    /// (peers then only notice via the discovered-room TTL). The local
1062    /// leave always succeeds regardless.
1063    pub async fn leave_room(&self, room_id: &str) -> Result<bool> {
1064        // Broadcast a leave notice before unsubscribing.
1065        let leave_msg = RoomMessage::MemberLeave {
1066            sender_fingerprint: self.identity.fingerprint().to_string(),
1067        };
1068        let dispatched = match encode_wire(&leave_msg) {
1069            Ok(bytes) => {
1070                self.network
1071                    .publish_room_message(room_id.to_string(), bytes)
1072                    .await;
1073                true
1074            }
1075            Err(e) => {
1076                warn!(%e, %room_id, "failed to encode MemberLeave notice");
1077                false
1078            }
1079        };
1080
1081        self.active_rooms.lock().unwrap().remove(room_id);
1082        self.network.unsubscribe_room(room_id.to_string()).await;
1083
1084        let _ = self.app_event_tx.send(AppEvent::RoomLeft {
1085            room_id: room_id.to_string(),
1086        });
1087        Ok(dispatched)
1088    }
1089
1090    pub async fn send_room_message(&self, room_id: &str, body: &str) -> Result<()> {
1091        let our_fp = self.identity.fingerprint().to_string();
1092        let msg = {
1093            let mut rooms = self.active_rooms.lock().unwrap();
1094            let room = rooms
1095                .get_mut(room_id)
1096                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
1097
1098            if room.read_only {
1099                return Err(HuddleError::Other(
1100                    "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(),
1101                ));
1102            }
1103
1104            if room.info.encrypted {
1105                let crypto = room
1106                    .crypto
1107                    .as_mut()
1108                    .ok_or_else(|| HuddleError::Session("encrypted room missing crypto".into()))?;
1109                let (session_id, ct_bytes) = crypto.encrypt(body.as_bytes())?;
1110                RoomMessage::Encrypted {
1111                    sender_fingerprint: our_fp.clone(),
1112                    session_id,
1113                    ciphertext_b64: base64::Engine::encode(
1114                        &base64::engine::general_purpose::STANDARD,
1115                        &ct_bytes,
1116                    ),
1117                }
1118            } else {
1119                RoomMessage::Plain {
1120                    sender_fingerprint: our_fp.clone(),
1121                    body: body.to_string(),
1122                }
1123            }
1124        };
1125
1126        let bytes = encode_wire(&msg)?;
1127        self.network
1128            .publish_room_message(room_id.to_string(), bytes)
1129            .await;
1130
1131        let now = now_unix();
1132        let msg_id =
1133            repo::insert_room_message(&self.db, room_id, &our_fp, "out", body, now)?;
1134        repo::update_room_last_active(&self.db, room_id, now)?;
1135
1136        let _ = self.app_event_tx.send(AppEvent::MessageSent {
1137            room_id: room_id.to_string(),
1138            body: body.to_string(),
1139            message_id: msg_id,
1140        });
1141
1142        Ok(())
1143    }
1144
1145    pub async fn shutdown(&self) {
1146        self.network.shutdown().await;
1147    }
1148
1149    // -------------------------------------------------------------------
1150    // Dial / known peers
1151    // -------------------------------------------------------------------
1152
1153    /// Dial a peer by a user-entered address. Accepts:
1154    /// - `1.2.3.4:9000`
1155    /// - `[fe80::1]:9000`
1156    /// - `/ip4/.../tcp/...[/p2p/<peer>]` (raw multiaddr)
1157    /// huddle 0.5.1: resolve an HD- ID or username back to a dialable
1158    /// multiaddr and dial it.
1159    ///
1160    /// `input` is matched against, in order:
1161    /// 1. an `HD-XXXX-...` prefixed string → strip prefix + lowercase to
1162    ///    canonical fingerprint;
1163    /// 2. a raw 24-char hex run (with or without dashes) → group into
1164    ///    4-char blocks and lowercase;
1165    /// 3. otherwise → treat as a username and look up `peer_profiles`.
1166    ///
1167    /// Resolution to an address: scan `discovered_rooms` for a room
1168    /// whose `creator_fingerprint` matches; take the first `host_addrs`
1169    /// entry. Falls back to the `known_peers` table for users we've
1170    /// dialed before. Both paths require we've seen the peer on our
1171    /// gossipsub mesh or dialed them before — bare-ID dialing on a
1172    /// cold mesh is fundamentally impossible without a routing layer
1173    /// huddle deliberately doesn't run (DHT, central directory). For
1174    /// cross-internet first contact, paste an invite link instead.
1175    pub async fn dial_by_id_or_username(&self, input: &str) -> Result<()> {
1176        let trimmed = input.trim();
1177        if trimmed.is_empty() {
1178            return Err(HuddleError::Other("input is empty".into()));
1179        }
1180        let target_fp = if let Some(fp) = normalize_to_fingerprint(trimmed) {
1181            fp
1182        } else {
1183            let matches = repo::find_peers_by_username(&self.db, trimmed)?;
1184            if matches.is_empty() {
1185                return Err(HuddleError::Other(format!(
1186                    "no peer named `{}` known yet — paste their invite link instead",
1187                    trimmed
1188                )));
1189            }
1190            if matches.len() > 1 {
1191                return Err(HuddleError::Other(format!(
1192                    "username `{}` is ambiguous ({} peers share it) — use their HD- ID instead",
1193                    trimmed,
1194                    matches.len()
1195                )));
1196            }
1197            matches.into_iter().next().unwrap()
1198        };
1199        if target_fp == self.identity.fingerprint() {
1200            return Err(HuddleError::Other("that's your own ID".into()));
1201        }
1202        let candidates = self.resolve_dial_addrs(&target_fp);
1203        if candidates.is_empty() {
1204            return Err(HuddleError::Other(format!(
1205                "haven't seen `{}` on the network yet — ask them for an invite link",
1206                short_fp_for_msg(&target_fp)
1207            )));
1208        }
1209        // Pre-record every candidate so the lobby's known-peers panel
1210        // surfaces them even before the post-identify handler lands.
1211        // We bind each address to the resolved fingerprint so the
1212        // post-identify trust upgrade has the same fp to confirm.
1213        let now = now_unix();
1214        for addr in &candidates {
1215            let _ = repo::upsert_known_peer(
1216                &self.db,
1217                &KnownPeer {
1218                    address: addr.clone(),
1219                    label: None,
1220                    last_connected_at: None,
1221                    last_attempt_at: Some(now),
1222                    created_at: now,
1223                    fingerprint: Some(target_fp.clone()),
1224                    trusted: false,
1225                },
1226            );
1227        }
1228        // Parse to Multiaddrs, drop any that don't lex. Empty after
1229        // parsing would mean every candidate is malformed — unlikely
1230        // but defended-against.
1231        let multiaddrs: Vec<Multiaddr> = candidates
1232            .iter()
1233            .filter_map(|s| s.parse::<Multiaddr>().ok())
1234            .collect();
1235        if multiaddrs.is_empty() {
1236            return Err(HuddleError::Other(
1237                "every known address for that peer is malformed".into(),
1238            ));
1239        }
1240        let _ = self.app_event_tx.send(AppEvent::Dialing {
1241            address: candidates[0].clone(),
1242        });
1243        info!(
1244            target_fp = %target_fp,
1245            n = multiaddrs.len(),
1246            "dialing peer with {} candidate addresses",
1247            multiaddrs.len()
1248        );
1249        // huddle 0.7.7: user-initiated dial — register every candidate
1250        // canonical address so whichever wins the libp2p race triggers
1251        // the post-identify auto-DM. Reset & insert under one lock.
1252        {
1253            let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
1254            for m in &multiaddrs {
1255                pending.insert(m.to_string());
1256            }
1257        }
1258        self.network.dial_addresses(multiaddrs).await;
1259        Ok(())
1260    }
1261
1262    /// huddle 0.5.2: every dialable multiaddr we know for `fingerprint`,
1263    /// sorted by transport preference so libp2p's parallel dialer races
1264    /// the cheapest paths first. Order: RFC1918 LAN ip4 → loopback (for
1265    /// tests) → public ip4 → ip6 / dns → relay-hopped (`/p2p-circuit`)
1266    /// last. libp2p races them concurrently anyway — sorting just
1267    /// gives the first-attempted slot to the address most likely to
1268    /// win on a tie.
1269    fn resolve_dial_addrs(&self, fingerprint: &str) -> Vec<String> {
1270        let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
1271        for room in self.discovered_rooms.lock().unwrap().values() {
1272            if room.creator_fingerprint == fingerprint {
1273                for addr in &room.host_addrs {
1274                    set.insert(addr.clone());
1275                }
1276            }
1277        }
1278        if let Ok(known) = repo::list_known_peers(&self.db) {
1279            for peer in known {
1280                if peer.fingerprint.as_deref() == Some(fingerprint) {
1281                    set.insert(peer.address);
1282                }
1283            }
1284        }
1285        let mut v: Vec<String> = set.into_iter().collect();
1286        v.sort_by_key(|a| address_preference(a));
1287        v
1288    }
1289
1290    pub async fn dial(&self, input: &str) -> Result<()> {
1291        let multiaddr = parse_dial_address(input)?;
1292        let canonical = multiaddr.to_string();
1293        // huddle 0.7.7: user-initiated entry point. Register the address
1294        // so the post-Identify handler auto-opens a DM with the peer.
1295        // The auto-reconnector goes through `dial_internal` instead and
1296        // therefore does NOT trigger an auto-DM on every startup.
1297        self.pending_auto_dm_addrs
1298            .lock()
1299            .unwrap()
1300            .insert(canonical.clone());
1301        self.dial_internal(canonical, multiaddr).await
1302    }
1303
1304    /// huddle 0.7.7: shared dial body used by the public `dial()` entry
1305    /// point and by internal reconnect paths. The two callers differ
1306    /// only in whether they register the address for auto-DM-after-
1307    /// identify; internal paths (startup reconnector, host-addr
1308    /// opportunistic dial) do not.
1309    pub(crate) async fn dial_internal(
1310        &self,
1311        canonical: String,
1312        multiaddr: Multiaddr,
1313    ) -> Result<()> {
1314        info!(%canonical, "dialing");
1315        repo::upsert_known_peer(
1316            &self.db,
1317            &KnownPeer {
1318                address: canonical.clone(),
1319                label: None,
1320                last_connected_at: None,
1321                last_attempt_at: Some(now_unix()),
1322                created_at: now_unix(),
1323                // Fingerprint isn't known until Identify lands after the
1324                // dial completes; the connection-success handler upserts
1325                // again with the fingerprint and trusted=true.
1326                fingerprint: None,
1327                trusted: false,
1328            },
1329        )?;
1330
1331        let _ = self.app_event_tx.send(AppEvent::Dialing {
1332            address: canonical.clone(),
1333        });
1334        self.network.dial(multiaddr).await;
1335        Ok(())
1336    }
1337
1338    /// Phase D follow-up: snapshot of the NAT reachability state.
1339    /// Returns the addresses AutoNAT has confirmed as externally
1340    /// reachable in this session. The lobby renders an emoji badge
1341    /// from this — non-empty ⇒ '🌐 reachable', empty ⇒ '🏠 LAN only'.
1342    pub fn nat_reachable_addrs(&self) -> Vec<String> {
1343        self.nat_reachable_addrs
1344            .lock()
1345            .unwrap()
1346            .iter()
1347            .cloned()
1348            .collect()
1349    }
1350
1351    /// Phase D follow-up: addresses suitable for putting on the wire
1352    /// so other peers can dial us. Union of:
1353    ///   - AutoNAT-confirmed external addresses (direct internet)
1354    ///   - active `/p2p-circuit` reservations on configured relays
1355    /// Capped at 4 entries to keep room announcements small.
1356    /// Relay-circuit addresses are listed first (they're more likely
1357    /// to work for NAT'd peers).
1358    pub fn dialable_addrs(&self) -> Vec<String> {
1359        let mut out: Vec<String> = self
1360            .relay_circuit_addrs
1361            .lock()
1362            .unwrap()
1363            .iter()
1364            .cloned()
1365            .collect();
1366        for a in self.nat_reachable_addrs.lock().unwrap().iter() {
1367            if !out.contains(a) {
1368                out.push(a.clone());
1369            }
1370        }
1371        out.truncate(4);
1372        out
1373    }
1374
1375    /// Phase C follow-up: dial a peer whose multiaddr came from an
1376    /// invite link claiming `claimed_fp`. Behaves identically to
1377    /// `dial`, but additionally stashes `(canonical_addr → claimed_fp)`
1378    /// in `pending_invite_dials` so the `PeerIdentified` handler can
1379    /// assert the cryptographic fp matches the human-display one in
1380    /// the invite. Mismatch ⇒ disconnect + `InviteFingerprintMismatch`
1381    /// event.
1382    ///
1383    /// libp2p's `/p2p/<peer-id>` segment already enforces this at the
1384    /// transport level (and our invite generator always includes it),
1385    /// so this is defense in depth — but it makes the assert explicit
1386    /// rather than relying on a structural side effect.
1387    pub async fn dial_invite(&self, address: &str, claimed_fp: &str) -> Result<()> {
1388        let multiaddr = parse_dial_address(address)?;
1389        let canonical = multiaddr.to_string();
1390        self.pending_invite_dials
1391            .lock()
1392            .unwrap()
1393            .insert(canonical.clone(), claimed_fp.to_string());
1394        // Re-use the standard dial path so KnownPeer rows + status
1395        // events look identical to a plain dial.
1396        self.dial(address).await
1397    }
1398
1399    pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1400        let connected = self.connected_dial_addrs.lock().unwrap().clone();
1401        let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1402        stored
1403            .into_iter()
1404            .map(|p| {
1405                let connected_peer = connected.get(&p.address).copied();
1406                KnownPeerStatus {
1407                    address: p.address,
1408                    label: p.label,
1409                    last_connected_at: p.last_connected_at,
1410                    connected_peer_id: connected_peer,
1411                    fingerprint: p.fingerprint,
1412                }
1413            })
1414            .collect()
1415    }
1416
1417    pub async fn forget_peer(&self, address: &str) -> Result<()> {
1418        repo::forget_known_peer(&self.db, address)?;
1419        self.connected_dial_addrs.lock().unwrap().remove(address);
1420        Ok(())
1421    }
1422
1423    /// Re-dial a stored address — used by the lobby's "reconnect" action.
1424    pub async fn redial(&self, address: &str) -> Result<()> {
1425        self.dial(address).await
1426    }
1427
1428    /// Phase A: user pressed Accept on the inbound-dial modal. Promotes
1429    /// the peer to the gossipsub mesh. Does NOT mark them trusted —
1430    /// that's `trust_inbound`, the explicit "remember and bypass next
1431    /// time" path.
1432    pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1433        self.network.accept_inbound(peer_id).await;
1434        self.connected_dial_addrs
1435            .lock()
1436            .unwrap()
1437            .insert(address.to_string(), peer_id);
1438    }
1439
1440    /// Phase A: user pressed Reject on the inbound-dial modal. Disconnects
1441    /// the peer, adds them to the persistent blocklist, and ensures every
1442    /// subsequent connection attempt from this fingerprint is auto-
1443    /// dropped without re-prompting.
1444    pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1445        self.network.reject_inbound(peer_id).await;
1446        repo::block_peer(&self.db, fingerprint, now_unix())?;
1447        Ok(())
1448    }
1449
1450    /// Phase A: user pressed Trust+Accept — accept the connection AND
1451    /// remember the peer so subsequent connections bypass the modal.
1452    pub async fn trust_inbound(
1453        &self,
1454        peer_id: PeerId,
1455        fingerprint: &str,
1456        address: &str,
1457    ) -> Result<()> {
1458        self.network.accept_inbound(peer_id).await;
1459        self.connected_dial_addrs
1460            .lock()
1461            .unwrap()
1462            .insert(address.to_string(), peer_id);
1463        // Persist the row with trusted=true so future inbound from
1464        // this fingerprint short-circuits the modal in
1465        // `process_network_event`'s InboundDial handler.
1466        repo::upsert_known_peer(
1467            &self.db,
1468            &KnownPeer {
1469                address: address.to_string(),
1470                label: None,
1471                last_connected_at: Some(now_unix()),
1472                last_attempt_at: Some(now_unix()),
1473                created_at: now_unix(),
1474                fingerprint: Some(fingerprint.to_string()),
1475                trusted: true,
1476            },
1477        )?;
1478        Ok(())
1479    }
1480
1481    // =========================================================================
1482    // huddle 0.7.7: pending friend requests (3-day TTL)
1483    // =========================================================================
1484
1485    /// Snapshot of every inbound dial we've spilled to disk but haven't
1486    /// yet accepted or rejected. The People pane renders this as its
1487    /// own section ("Pending requests (N)").
1488    pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
1489        repo::list_pending_friend_requests(&self.db).unwrap_or_default()
1490    }
1491
1492    /// Persist an inbound request that the user didn't act on within the
1493    /// modal window. Called from the TUI's idle-timeout sweep; the live
1494    /// libp2p connection is also closed by the same path (the request
1495    /// is effectively rejected *for now* — accept later from People
1496    /// pane will re-dial the stored address).
1497    pub fn spill_pending_friend_request(
1498        &self,
1499        peer_id: PeerId,
1500        fingerprint: &str,
1501        address: &str,
1502    ) -> Result<()> {
1503        repo::upsert_pending_friend_request(
1504            &self.db,
1505            &repo::PendingFriendRequest {
1506                fingerprint: fingerprint.to_string(),
1507                address: address.to_string(),
1508                peer_id: peer_id.to_string(),
1509                received_at: now_unix(),
1510            },
1511        )?;
1512        Ok(())
1513    }
1514
1515    /// User pressed Accept on a row in the Pending requests list. The
1516    /// original libp2p connection is long gone (we closed it on
1517    /// timeout); re-dial the stored address and mark the peer trusted
1518    /// so the post-Identify handler short-circuits the modal. The
1519    /// row is removed regardless of dial success — a failed dial is
1520    /// still a positive intent we don't want to keep re-prompting on.
1521    pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1522        let mut chosen_addr: Option<String> = None;
1523        for req in self.list_pending_friend_requests() {
1524            if req.fingerprint == fingerprint {
1525                chosen_addr = Some(req.address);
1526                break;
1527            }
1528        }
1529        repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1530        if let Some(addr) = chosen_addr {
1531            // Pre-mark trusted so the upcoming Identify handler skips
1532            // the inbound-dial modal. Matches the semantics of
1533            // `trust_inbound` without needing a live PeerId.
1534            repo::upsert_known_peer(
1535                &self.db,
1536                &KnownPeer {
1537                    address: addr.clone(),
1538                    label: None,
1539                    last_connected_at: None,
1540                    last_attempt_at: Some(now_unix()),
1541                    created_at: now_unix(),
1542                    fingerprint: Some(fingerprint.to_string()),
1543                    trusted: true,
1544                },
1545            )?;
1546            // User-initiated — register for auto-DM on connect.
1547            self.dial(&addr).await?;
1548        }
1549        Ok(())
1550    }
1551
1552    /// User pressed Reject on a row in the Pending requests list.
1553    /// Mirrors `reject_inbound` semantics: delete the pending row(s)
1554    /// AND block the fingerprint so any future dial from this peer is
1555    /// auto-dropped without re-prompting.
1556    pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1557        repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1558        repo::block_peer(&self.db, fingerprint, now_unix())?;
1559        Ok(())
1560    }
1561
1562    /// huddle 0.7.7: close a live libp2p connection without blocking the
1563    /// peer. Used by the TUI's 15s InboundDial timeout — we need to
1564    /// drop the dangling socket, but blocking the peer would
1565    /// contradict "save the request for 3 days, let the user decide
1566    /// later." `reject_inbound` is the right call when the user
1567    /// *explicitly* clicks Reject.
1568    pub async fn disconnect_peer(&self, peer_id: PeerId) {
1569        self.network.disconnect_peer(peer_id).await;
1570    }
1571
1572    fn spawn_known_peer_reconnector(&self) {
1573        let handle = self.clone();
1574        tokio::spawn(async move {
1575            // Brief delay so our own listeners come up first.
1576            tokio::time::sleep(Duration::from_millis(500)).await;
1577            let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1578            // Reconnect each peer from its own task on a staggered, jittered
1579            // delay so a long known-peer list doesn't fire a synchronized
1580            // burst of dials (and serialized DB writes) all at once.
1581            for (i, peer) in known.into_iter().enumerate() {
1582                let handle = handle.clone();
1583                tokio::spawn(async move {
1584                    // Deterministic per-address jitter de-correlates peers
1585                    // without pulling an RNG into scope.
1586                    let jitter = (peer.address.len() as u64 * 37) % 200;
1587                    tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1588                    // huddle 0.7.7: route through `dial_internal`, NOT
1589                    // `dial`. Startup reconnects shouldn't pop a DM
1590                    // every time a known peer comes online — only
1591                    // explicit user actions trigger the auto-DM.
1592                    let multiaddr = match peer.address.parse::<Multiaddr>() {
1593                        Ok(m) => m,
1594                        Err(_) => return,
1595                    };
1596                    if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
1597                        debug!(%e, addr = %peer.address, "auto-reconnect failed");
1598                    }
1599                });
1600            }
1601        });
1602    }
1603
1604    // -------------------------------------------------------------------
1605    // Internal helpers
1606    // -------------------------------------------------------------------
1607
1608    fn load_or_create_identity(db: &Db) -> Result<Identity> {
1609        if let Some(stored) = repo::load_identity(db)? {
1610            let mut bytes = [0u8; 32];
1611            bytes.copy_from_slice(&stored.ed25519_secret);
1612            Identity::from_secret_bytes(bytes)
1613        } else {
1614            let id = Identity::generate()?;
1615            repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1616            Ok(id)
1617        }
1618    }
1619
1620    fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1621        self.active_rooms
1622            .lock()
1623            .unwrap()
1624            .get(room_id)
1625            .and_then(|r| r.info.passphrase_salt.clone())
1626            .or_else(|| {
1627                // Try the cached announcement salt
1628                ROOM_SALT_CACHE
1629                    .lock()
1630                    .unwrap()
1631                    .get(room_id)
1632                    .cloned()
1633            })
1634    }
1635
1636    async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1637        let owner_fingerprints =
1638            repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1639        let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1640        let host_addrs = self.dialable_addrs();
1641        let ann = RoomAnnouncement {
1642            room_id: info.id.clone(),
1643            name: info.name.clone(),
1644            encrypted: info.encrypted,
1645            passphrase_salt: info.passphrase_salt.clone(),
1646            member_count,
1647            creator_fingerprint: info.creator_fingerprint.clone(),
1648            announced_at: now_unix(),
1649            owner_fingerprints,
1650            verified_only,
1651            host_addrs,
1652            kind: info.kind,
1653        };
1654        self.network.announce_room(ann).await;
1655    }
1656
1657    async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1658        let our_fp = self.identity.fingerprint().to_string();
1659        let wrapped = {
1660            let mut rooms = self.active_rooms.lock().unwrap();
1661            let room = rooms
1662                .get_mut(room_id)
1663                .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1664            if room.info.encrypted {
1665                let crypto = room.crypto.as_mut().unwrap();
1666                let session_key = crypto.our_session_key_b64();
1667                match room.passphrase_key.as_ref() {
1668                    Some(passphrase_key) => {
1669                        Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1670                    }
1671                    None if room.info.kind == RoomKind::Direct => {
1672                        // huddle 0.7.1: DM-specific path — partner's
1673                        // pubkey hasn't been observed yet, so we can't
1674                        // derive the ECDH key. Send announce without
1675                        // a wrapped key — it carries our Ed25519
1676                        // pubkey, which lets the partner derive the
1677                        // key on their side. They'll respond with
1678                        // their own wrapped key in a follow-up
1679                        // announce; once we receive it we re-broadcast
1680                        // ours with the wrap filled in.
1681                        None
1682                    }
1683                    None => {
1684                        return Err(HuddleError::Session("missing passphrase key".into()));
1685                    }
1686                }
1687            } else {
1688                None
1689            }
1690        };
1691        let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1692        let msg = RoomMessage::MemberAnnounce {
1693            sender_fingerprint: our_fp,
1694            wrapped_session_key: wrapped,
1695            display_name,
1696            sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1697        };
1698        let bytes = encode_wire(&msg)?;
1699        self.network
1700            .publish_room_message(room_id.to_string(), bytes)
1701            .await;
1702        Ok(())
1703    }
1704
1705    fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1706        let handle = self.clone();
1707        tokio::spawn(async move {
1708            while let Some(event) = net_rx.recv().await {
1709                handle.process_network_event(event).await;
1710            }
1711            info!("event processor stopped");
1712        });
1713    }
1714
1715    fn spawn_announcement_ticker(&self) {
1716        let handle = self.clone();
1717        tokio::spawn(async move {
1718            let mut interval =
1719                tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1720            interval.tick().await; // skip the immediate tick
1721            loop {
1722                interval.tick().await;
1723                let snapshot: Vec<(StoredRoom, u32)> = {
1724                    let active = handle.active_rooms.lock().unwrap();
1725                    active
1726                        .values()
1727                        .map(|r| (r.info.clone(), r.members.len() as u32))
1728                        .collect()
1729                };
1730                for (info, member_count) in snapshot {
1731                    handle.announce_room_now(&info, member_count).await;
1732                }
1733            }
1734        });
1735    }
1736
1737    fn spawn_discovered_room_pruner(&self) {
1738        let handle = self.clone();
1739        tokio::spawn(async move {
1740            let mut interval = tokio::time::interval(Duration::from_secs(10));
1741            interval.tick().await;
1742            loop {
1743                interval.tick().await;
1744                let now = now_unix();
1745                let mut to_drop = Vec::new();
1746                {
1747                    let mut map = handle.discovered_rooms.lock().unwrap();
1748                    map.retain(|id, r| {
1749                        if now - r.last_seen > DISCOVERED_TTL_SECS {
1750                            to_drop.push(id.clone());
1751                            false
1752                        } else {
1753                            true
1754                        }
1755                    });
1756                }
1757                for id in to_drop {
1758                    let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1759                }
1760            }
1761        });
1762    }
1763
1764    async fn process_network_event(&self, event: NetworkEvent) {
1765        match event {
1766            NetworkEvent::PeerDiscovered { peer_id } => {
1767                let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1768            }
1769            NetworkEvent::PeerExpired { peer_id } => {
1770                // Drop any tracked dial-connection entry for this peer so
1771                // the lobby's online/offline dots stay accurate. mDNS
1772                // expiry only gives us a PeerId (no fingerprint), so we
1773                // can't touch room membership here — that relies on the
1774                // explicit MemberLeave path and the discovered-room TTL.
1775                self.connected_dial_addrs
1776                    .lock()
1777                    .unwrap()
1778                    .retain(|_addr, pid| *pid != peer_id);
1779                let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1780            }
1781            NetworkEvent::ListeningOn { address } => {
1782                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1783                    address: address.to_string(),
1784                });
1785            }
1786            NetworkEvent::RoomAnnouncementReceived(ann) => {
1787                // Cache the salt for join_room
1788                if let Some(salt) = &ann.passphrase_salt {
1789                    ROOM_SALT_CACHE
1790                        .lock()
1791                        .unwrap()
1792                        .insert(ann.room_id.clone(), salt.clone());
1793                }
1794                // Phase D follow-up: opportunistically dial the
1795                // announcer's first host_addr if we're not already
1796                // connected. Skips self-announcements + rate-limits
1797                // by creator fingerprint so we don't dial-storm.
1798                let our_fp_for_dial = self.identity.fingerprint().to_string();
1799                if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1800                    let now = now_unix();
1801                    let should_dial = {
1802                        let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1803                        match attempts.get(&ann.creator_fingerprint).copied() {
1804                            Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1805                            _ => {
1806                                attempts.insert(ann.creator_fingerprint.clone(), now);
1807                                true
1808                            }
1809                        }
1810                    };
1811                    if should_dial {
1812                        if let Some(first) = ann.host_addrs.first() {
1813                            info!(
1814                                announcer = %ann.creator_fingerprint,
1815                                addr = %first,
1816                                "opportunistic dial via room announcement host_addrs"
1817                            );
1818                            // huddle 0.7.7: NOT user-initiated — go
1819                            // through `dial_internal` so a passive
1820                            // announcement-driven dial doesn't pop a
1821                            // DM in the user's face.
1822                            if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1823                                let canonical = multiaddr.to_string();
1824                                let _ = self.dial_internal(canonical, multiaddr).await;
1825                            }
1826                        }
1827                    }
1828                }
1829                let discovered = DiscoveredRoom {
1830                    room_id: ann.room_id.clone(),
1831                    name: ann.name.clone(),
1832                    encrypted: ann.encrypted,
1833                    member_count: ann.member_count,
1834                    creator_fingerprint: ann.creator_fingerprint.clone(),
1835                    last_seen: now_unix(),
1836                    restorable: false,
1837                    host_addrs: ann.host_addrs.clone(),
1838                    kind: ann.kind,
1839                };
1840                // If we're already in this room, cache the announcement so
1841                // others can still discover it through us, but don't emit
1842                // RoomDiscovered — it isn't "newly discovered" to us, and
1843                // emitting it spuriously re-opens the lobby join prompt.
1844                if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1845                    self.discovered_rooms
1846                        .lock()
1847                        .unwrap()
1848                        .insert(ann.room_id.clone(), discovered);
1849                    return;
1850                }
1851                // huddle 0.7 DM-visibility filter (consumer side): a
1852                // `Direct` announcement is only valid for the two members
1853                // implied by `canonical_dm_room_id`. If we're not one of
1854                // them, silently drop — DMs never appear in third
1855                // parties' discovery caches. A malicious 0.7+ peer can
1856                // ignore this, but they'd have to subscribe to the
1857                // canonical DM topic with full knowledge of both
1858                // fingerprints, which is a stronger threat than the v1
1859                // sidebar split is trying to mitigate.
1860                if ann.kind == RoomKind::Direct {
1861                    let our_fp_for_filter = self.identity.fingerprint().to_string();
1862                    if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1863                        != ann.room_id
1864                    {
1865                        debug!(
1866                            announcer = %ann.creator_fingerprint,
1867                            room_id = %ann.room_id,
1868                            "dropping Direct announcement: not addressed to us"
1869                        );
1870                        return;
1871                    }
1872                    // Targeted at us. Cache the discovery so the sidebar
1873                    // can show "DM from <partner>" and auto-bootstrap a
1874                    // local active room so we can receive messages
1875                    // immediately without waiting for a user action.
1876                    self.discovered_rooms
1877                        .lock()
1878                        .unwrap()
1879                        .insert(ann.room_id.clone(), discovered.clone());
1880                    let _ = self
1881                        .app_event_tx
1882                        .send(AppEvent::RoomDiscovered(discovered.clone()));
1883                    let app = self.clone();
1884                    let partner = ann.creator_fingerprint.clone();
1885                    let rid = ann.room_id.clone();
1886                    tokio::spawn(async move {
1887                        if let Err(e) = app.start_direct(&partner).await {
1888                            debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1889                        }
1890                    });
1891                    return;
1892                }
1893                self.discovered_rooms
1894                    .lock()
1895                    .unwrap()
1896                    .insert(ann.room_id.clone(), discovered.clone());
1897                let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1898            }
1899            NetworkEvent::RoomMessageReceived {
1900                room_id,
1901                payload,
1902                from_peer: _,
1903            } => {
1904                // v0.3.0+: every wire message is a `WireMessage` envelope.
1905                // `Plain` carries an unsigned `RoomMessage`; `Signed` is an
1906                // app-level Ed25519 envelope that we verify before
1907                // unwrapping. A failed verify is logged and dropped — we
1908                // never dispatch unverified-but-claiming-to-be-signed
1909                // messages.
1910                let wire: WireMessage = match serde_json::from_slice(&payload) {
1911                    Ok(w) => w,
1912                    Err(e) => {
1913                        warn!(%e, "bad wire envelope");
1914                        return;
1915                    }
1916                };
1917                let (msg, verified_signer) = match wire {
1918                    WireMessage::Plain(m) => (m, None),
1919                    WireMessage::Signed(env) => {
1920                        let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1921                        match crate::crypto::verify_signed(&env) {
1922                            Ok((m, fp)) => {
1923                                // Defense in depth: if we've persisted
1924                                // a pubkey for this fingerprint in this
1925                                // room before, the envelope's pubkey
1926                                // MUST match it. A different pubkey for
1927                                // the same fingerprint means identity
1928                                // drift — TOFU violation — drop.
1929                                match repo::get_member_ed25519_pubkey(
1930                                    &self.db, &room_id, &fp,
1931                                ) {
1932                                    Ok(Some(known)) if known != claimed_pubkey => {
1933                                        warn!(
1934                                            %fp, %room_id,
1935                                            "pubkey mismatch vs stored; dropping signed message"
1936                                        );
1937                                        return;
1938                                    }
1939                                    _ => {}
1940                                }
1941                                (m, Some(fp))
1942                            }
1943                            Err(e) => {
1944                                warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
1945                                return;
1946                            }
1947                        }
1948                    }
1949                };
1950                self.handle_room_message(&room_id, msg, verified_signer).await;
1951            }
1952            NetworkEvent::DialSucceeded { peer_id, address } => {
1953                let addr_s = address.to_string();
1954                self.connected_dial_addrs
1955                    .lock()
1956                    .unwrap()
1957                    .insert(addr_s.clone(), peer_id);
1958                // Fingerprint isn't known yet (Identify hasn't landed);
1959                // the PeerIdentified handler below upserts again to add
1960                // the fingerprint and flip trusted=true once it does.
1961                let _ = repo::upsert_known_peer(
1962                    &self.db,
1963                    &KnownPeer {
1964                        address: addr_s.clone(),
1965                        label: None,
1966                        last_connected_at: Some(now_unix()),
1967                        last_attempt_at: Some(now_unix()),
1968                        created_at: now_unix(),
1969                        fingerprint: None,
1970                        trusted: false,
1971                    },
1972                );
1973                let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
1974                    address: addr_s,
1975                    peer_id,
1976                });
1977            }
1978            NetworkEvent::DialFailed { address, error } => {
1979                let addr_s = address.to_string();
1980                let _ = self.app_event_tx.send(AppEvent::DialFailed {
1981                    address: addr_s,
1982                    error,
1983                });
1984            }
1985            NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
1986                // For any address we user-dialed for this peer, retroactively
1987                // backfill the fingerprint and flip trusted=true. The
1988                // upsert's COALESCE preserves fingerprint once set and
1989                // its trusted-is-sticky-once-true clause means we don't
1990                // accidentally demote a row that was already trusted.
1991                let matched_addrs: Vec<String> = {
1992                    let map = self.connected_dial_addrs.lock().unwrap();
1993                    map.iter()
1994                        .filter_map(|(addr, pid)| {
1995                            if *pid == peer_id {
1996                                Some(addr.clone())
1997                            } else {
1998                                None
1999                            }
2000                        })
2001                        .collect()
2002                };
2003                // Phase C follow-up: if any of these addresses came
2004                // from an invite, verify the invite's claimed fp
2005                // against what we just derived from the pubkey. A
2006                // mismatch means the invite's fp label disagrees with
2007                // libp2p's /p2p/<peer-id> cryptographic anchor —
2008                // structurally impossible when both fields are
2009                // generated from the same identity, but the explicit
2010                // assert defends against future invite-format
2011                // changes or hand-edited links.
2012                let mismatch = {
2013                    let mut map = self.pending_invite_dials.lock().unwrap();
2014                    let mut found: Option<(String, String)> = None;
2015                    for addr in &matched_addrs {
2016                        if let Some(claimed) = map.remove(addr) {
2017                            if claimed != fingerprint {
2018                                found = Some((addr.clone(), claimed));
2019                                break;
2020                            }
2021                        }
2022                    }
2023                    found
2024                };
2025                if let Some((addr, claimed)) = mismatch {
2026                    warn!(
2027                        %addr, %claimed, actual=%fingerprint,
2028                        "invite fingerprint mismatch — disconnecting"
2029                    );
2030                    self.network.disconnect_peer(peer_id).await;
2031                    let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2032                        address: addr,
2033                        claimed,
2034                        actual: fingerprint.clone(),
2035                    });
2036                    return;
2037                }
2038                // huddle 0.7.7: did the local user initiate any of these
2039                // dials? If so, consume the matching entries from
2040                // `pending_auto_dm_addrs` now so we don't auto-DM
2041                // again on a subsequent reconnect. The actual DM
2042                // start happens after the trust upsert below so the
2043                // peer is already marked trusted by the time we fire.
2044                let should_auto_dm = {
2045                    let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2046                    let mut any_matched = false;
2047                    for addr in &matched_addrs {
2048                        if pending.remove(addr) {
2049                            any_matched = true;
2050                        }
2051                    }
2052                    any_matched
2053                };
2054                for addr in matched_addrs {
2055                    let _ = repo::upsert_known_peer(
2056                        &self.db,
2057                        &KnownPeer {
2058                            address: addr,
2059                            label: None,
2060                            last_connected_at: Some(now_unix()),
2061                            last_attempt_at: Some(now_unix()),
2062                            created_at: now_unix(),
2063                            fingerprint: Some(fingerprint.clone()),
2064                            trusted: true,
2065                        },
2066                    );
2067                }
2068                // huddle 0.7.7: open (or reuse) a DM with the freshly
2069                // identified peer and tell the TUI to switch panes.
2070                // `start_direct` is idempotent on `canonical_dm_room_id`,
2071                // so this is safe to call even if a DM already exists.
2072                // Blocked peers fall through `start_direct` naturally —
2073                // they won't reach trust + identify at all.
2074                if should_auto_dm && fingerprint != self.identity.fingerprint() {
2075                    match self.start_direct(&fingerprint).await {
2076                        Ok(room_id) => {
2077                            let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2078                                room_id,
2079                                fingerprint: fingerprint.clone(),
2080                            });
2081                        }
2082                        Err(e) => {
2083                            debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2084                        }
2085                    }
2086                }
2087                // huddle 0.5: tell the newly-identified peer our current
2088                // username via a signed ProfileUpdate, but only if we
2089                // have one set locally and we haven't already pushed
2090                // ours to this peer in the last
2091                // `PROFILE_REBROADCAST_FLOOR_MS`. Without the floor a
2092                // flapping transport (relay reconnect storms) would
2093                // republish on every identify event.
2094                let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2095                if our_username.is_some() {
2096                    let now_ms = now_unix_ms();
2097                    let should_send = {
2098                        let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2099                        match last.get(&fingerprint) {
2100                            Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2101                            _ => {
2102                                last.insert(fingerprint.clone(), now_ms);
2103                                true
2104                            }
2105                        }
2106                    };
2107                    if should_send {
2108                        let msg = RoomMessage::ProfileUpdate {
2109                            sender_fingerprint: self.identity.fingerprint().to_string(),
2110                            username: our_username,
2111                            updated_at: now_ms,
2112                        };
2113                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2114                            if let Ok(bytes) =
2115                                crate::network::protocol::encode_wire_signed(&env)
2116                            {
2117                                let rooms: Vec<String> = self
2118                                    .active_rooms
2119                                    .lock()
2120                                    .unwrap()
2121                                    .keys()
2122                                    .cloned()
2123                                    .collect();
2124                                for room_id in rooms {
2125                                    self.network
2126                                        .publish_room_message(room_id, bytes.clone())
2127                                        .await;
2128                                }
2129                            }
2130                        }
2131                    }
2132                }
2133            }
2134            NetworkEvent::RelayReservationEstablished { address } => {
2135                // Treat the circuit address like any other listen
2136                // address — the TUI's ListeningOn handler dedups + adds
2137                // it to the addresses pane. Also emit a status hint via
2138                // ListeningOn so the lobby's reachability line updates.
2139                info!(addr = %address, "relay reservation established");
2140                self.relay_circuit_addrs
2141                    .lock()
2142                    .unwrap()
2143                    .insert(address.to_string());
2144                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2145                    address: address.to_string(),
2146                });
2147            }
2148            NetworkEvent::NatProbeResult {
2149                tested_addr,
2150                reachable,
2151            } => {
2152                let addr_s = tested_addr.to_string();
2153                let (transitioned, becomes_reachable) = {
2154                    let mut set = self.nat_reachable_addrs.lock().unwrap();
2155                    let was_empty = set.is_empty();
2156                    if reachable {
2157                        set.insert(addr_s.clone());
2158                    } else {
2159                        set.remove(&addr_s);
2160                    }
2161                    let is_empty = set.is_empty();
2162                    (was_empty != is_empty, !is_empty)
2163                };
2164                if transitioned {
2165                    let label = if becomes_reachable {
2166                        "reachable".to_string()
2167                    } else {
2168                        "private".to_string()
2169                    };
2170                    info!(reachable = %becomes_reachable, "NAT reachability changed");
2171                    let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2172                        label,
2173                        reachable: becomes_reachable,
2174                    });
2175                }
2176            }
2177            NetworkEvent::DcutrUpgrade {
2178                remote_peer,
2179                success,
2180            } => {
2181                if success {
2182                    // Render the peer as the last 8 chars of the
2183                    // PeerId for compactness — full peer id is too long
2184                    // for a status line.
2185                    let s = remote_peer.to_base58();
2186                    let tail: String = s.chars().rev().take(8).collect::<String>()
2187                        .chars()
2188                        .rev()
2189                        .collect();
2190                    let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2191                        peer_label: tail,
2192                    });
2193                }
2194            }
2195            NetworkEvent::InboundDial {
2196                peer_id,
2197                fingerprint,
2198                address,
2199            } => {
2200                // First: cheap server-side filters before bothering the user.
2201                if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2202                    info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2203                    self.network.reject_inbound(peer_id).await;
2204                    return;
2205                }
2206                // Phase E: global verified-only inbound mode. If on,
2207                // reject any unverified fingerprint without prompting.
2208                // SAS-verified (Phase G) and already-trusted (Phase A)
2209                // peers still come through.
2210                let global_verified_only =
2211                    repo::get_setting(&self.db, "verified_only_inbound")
2212                        .ok()
2213                        .flatten()
2214                        .map(|v| v == "1")
2215                        .unwrap_or(false);
2216                if global_verified_only {
2217                    let is_verified =
2218                        repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2219                            || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2220                                .unwrap_or(false);
2221                    if !is_verified {
2222                        info!(
2223                            %fingerprint,
2224                            "inbound dial auto-rejected: verified-only mode"
2225                        );
2226                        self.network.reject_inbound(peer_id).await;
2227                        return;
2228                    }
2229                }
2230                if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2231                    info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2232                    // Persist the address → peer_id mapping just as a
2233                    // user-dial would, so the lobby's online dot lights up.
2234                    self.connected_dial_addrs
2235                        .lock()
2236                        .unwrap()
2237                        .insert(address.to_string(), peer_id);
2238                    let _ = repo::upsert_known_peer(
2239                        &self.db,
2240                        &KnownPeer {
2241                            address: address.to_string(),
2242                            label: None,
2243                            last_connected_at: Some(now_unix()),
2244                            last_attempt_at: Some(now_unix()),
2245                            created_at: now_unix(),
2246                            fingerprint: Some(fingerprint),
2247                            trusted: true,
2248                        },
2249                    );
2250                    self.network.accept_inbound(peer_id).await;
2251                    return;
2252                }
2253                // Unknown peer — surface the modal in the TUI.
2254                let _ = self.app_event_tx.send(AppEvent::InboundDial {
2255                    peer_id,
2256                    fingerprint,
2257                    address: address.to_string(),
2258                });
2259            }
2260        }
2261    }
2262
2263    /// `verified_signer` is `Some(fp)` if this message arrived inside a
2264    /// successfully-verified `WireMessage::Signed` envelope — in which
2265    /// case the inner sender_fingerprint *must* match. `None` for
2266    /// `WireMessage::Plain`. Phase B's `OwnerGrant`/`BanMember` arms
2267    /// require it to be `Some` AND the signer to be a current owner.
2268    async fn handle_room_message(
2269        &self,
2270        room_id: &str,
2271        msg: RoomMessage,
2272        verified_signer: Option<String>,
2273    ) {
2274        let our_fp = self.identity.fingerprint().to_string();
2275        match msg {
2276            RoomMessage::MemberAnnounce {
2277                sender_fingerprint,
2278                wrapped_session_key,
2279                display_name,
2280                sender_ed25519_pubkey,
2281            } => {
2282                if sender_fingerprint == our_fp {
2283                    return;
2284                }
2285                // Drop announcements from banned fingerprints — they
2286                // can't rejoin until an owner unbans them (Phase B).
2287                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2288                    .unwrap_or(false)
2289                {
2290                    info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2291                    return;
2292                }
2293                // Phase E per-room enforcement: if this room is
2294                // verified-only and the joiner isn't globally SAS-
2295                // verified, refuse to add them. The lowest-fp owner
2296                // (deterministic across honest peers) also sends a
2297                // signed `JoinRefused` so the joiner gets an explicit
2298                // message instead of a silent hang.
2299                if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2300                    && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2301                {
2302                    info!(
2303                        %sender_fingerprint, %room_id,
2304                        "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2305                    );
2306                    let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2307                    let lowest_owner = owners.iter().min().cloned();
2308                    if lowest_owner.as_deref() == Some(&our_fp) {
2309                        let msg = RoomMessage::JoinRefused {
2310                            room_id: room_id.to_string(),
2311                            target_fingerprint: sender_fingerprint.clone(),
2312                            reason: "room requires SAS verification — ask an existing member to verify you".into(),
2313                        };
2314                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2315                            if let Ok(bytes) =
2316                                crate::network::protocol::encode_wire_signed(&env)
2317                            {
2318                                self.network
2319                                    .publish_room_message(room_id.to_string(), bytes)
2320                                    .await;
2321                            }
2322                        }
2323                    }
2324                    return;
2325                }
2326                let need_inbound = {
2327                    let mut rooms = self.active_rooms.lock().unwrap();
2328                    let room = match rooms.get_mut(room_id) {
2329                        Some(r) => r,
2330                        None => return,
2331                    };
2332                    // huddle 0.7: Direct rooms are 1-1 forever. If a
2333                    // third fingerprint announces, drop it locally and
2334                    // skip the persist/wrap-session path. This is honest-
2335                    // client enforcement — a malicious peer with the
2336                    // canonical DM passphrase-equivalent could still
2337                    // chat, but they'd never be visible in our sidebar
2338                    // or render in the DM pane.
2339                    if room.info.kind == RoomKind::Direct
2340                        && !room.members.contains(&sender_fingerprint)
2341                        && room.members.len() >= 2
2342                    {
2343                        info!(
2344                            %sender_fingerprint, %room_id,
2345                            "dropping MemberAnnounce on Direct room: already at 2-member cap"
2346                        );
2347                        return;
2348                    }
2349                    let newly_added = room.members.insert(sender_fingerprint.clone());
2350                    if newly_added {
2351                        let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2352                            room_id: room_id.to_string(),
2353                            fingerprint: sender_fingerprint.clone(),
2354                        });
2355                    }
2356                    // Persist member with optional display name + pubkey.
2357                    // `ed25519_pubkey` is `None` for pre-0.3 peers; the
2358                    // upsert COALESCEs so once we learn it we never lose
2359                    // it on a later announce that drops the field.
2360                    let _ = repo::upsert_room_member(
2361                        &self.db,
2362                        &StoredRoomMember {
2363                            room_id: room_id.to_string(),
2364                            peer_id: String::new(), // unknown at this layer
2365                            fingerprint: sender_fingerprint.clone(),
2366                            last_seen: Some(now_unix()),
2367                            verified: false,
2368                            ed25519_pubkey: sender_ed25519_pubkey.clone(),
2369                            // Role is set on first insert only — the
2370                            // upsert ON CONFLICT clause preserves an
2371                            // existing 'owner' on re-announce. A genuine
2372                            // new fingerprint is a 'member' until an
2373                            // OwnerGrant lands.
2374                            role: "member".into(),
2375                        },
2376                    );
2377                    if let Some(name) = display_name.as_deref() {
2378                        let _ = repo::set_member_display_name(
2379                            &self.db,
2380                            room_id,
2381                            &sender_fingerprint,
2382                            Some(name),
2383                        );
2384                    }
2385                    room.info.encrypted && wrapped_session_key.is_some()
2386                };
2387
2388                // huddle 0.7.1: for Direct rooms, the passphrase_key is
2389                // derived from ECDH between our identity key and the
2390                // partner's. The partner's pubkey may arrive in *this*
2391                // MemberAnnounce — so we lazily compute the key now,
2392                // before the unwrap path runs. Idempotent: if we
2393                // already have the key, this is a no-op.
2394                if matches!(
2395                    self.active_rooms
2396                        .lock()
2397                        .unwrap()
2398                        .get(room_id)
2399                        .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2400                    Some((RoomKind::Direct, true))
2401                ) {
2402                    if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2403                        if let Some(key) =
2404                            self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2405                        {
2406                            let mut rooms = self.active_rooms.lock().unwrap();
2407                            if let Some(room) = rooms.get_mut(room_id) {
2408                                room.passphrase_key = Some(key);
2409                            }
2410                            drop(rooms);
2411                            // We just got the key — re-broadcast our
2412                            // MemberAnnounce so the partner gets our
2413                            // wrapped session key. Fire-and-forget;
2414                            // failures are logged.
2415                            let app = self.clone();
2416                            let rid = room_id.to_string();
2417                            tokio::spawn(async move {
2418                                if let Err(e) = app.broadcast_member_announce(&rid).await {
2419                                    warn!(%e, "re-broadcast DM announce after key derivation");
2420                                }
2421                            });
2422                        }
2423                    }
2424                }
2425
2426                if need_inbound {
2427                    let wrapped = wrapped_session_key.unwrap();
2428                    let result = {
2429                        let mut rooms = self.active_rooms.lock().unwrap();
2430                        let room = rooms.get_mut(room_id).unwrap();
2431                        let passphrase_key = match &room.passphrase_key {
2432                            Some(k) => k,
2433                            None => {
2434                                warn!("no passphrase key when receiving session key");
2435                                return;
2436                            }
2437                        };
2438                        match passphrase::unwrap(&wrapped, passphrase_key) {
2439                            Ok(plain) => match String::from_utf8(plain) {
2440                                Ok(key_b64) => {
2441                                    let crypto = room.crypto.as_mut().unwrap();
2442                                    crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2443                                }
2444                                Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2445                            },
2446                            Err(e) => Err(e),
2447                        }
2448                    };
2449                    if let Err(e) = result {
2450                        error!(%e, "add inbound session failed");
2451                    }
2452                }
2453            }
2454            RoomMessage::SessionKeyRequest {
2455                requester_fingerprint,
2456            } => {
2457                if requester_fingerprint == our_fp {
2458                    return;
2459                }
2460                // Re-announce ourselves to share our session key with the new joiner.
2461                if let Err(e) = self.broadcast_member_announce(room_id).await {
2462                    warn!(%e, "broadcast member announce on request");
2463                }
2464            }
2465            RoomMessage::Encrypted {
2466                sender_fingerprint,
2467                session_id,
2468                ciphertext_b64,
2469            } => {
2470                if sender_fingerprint == our_fp {
2471                    return;
2472                }
2473                let ct_bytes = match base64::Engine::decode(
2474                    &base64::engine::general_purpose::STANDARD,
2475                    &ciphertext_b64,
2476                ) {
2477                    Ok(b) => b,
2478                    Err(e) => {
2479                        warn!(%e, "bad base64 ciphertext");
2480                        return;
2481                    }
2482                };
2483                let plaintext = {
2484                    let mut rooms = self.active_rooms.lock().unwrap();
2485                    let room = match rooms.get_mut(room_id) {
2486                        Some(r) => r,
2487                        None => return,
2488                    };
2489                    let crypto = match room.crypto.as_mut() {
2490                        Some(c) => c,
2491                        None => return,
2492                    };
2493                    crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2494                };
2495                match plaintext {
2496                    Ok(pt) => {
2497                        let body = String::from_utf8_lossy(&pt).to_string();
2498                        let sent_at = now_unix();
2499                        let _ = repo::insert_room_message(
2500                            &self.db,
2501                            room_id,
2502                            &sender_fingerprint,
2503                            "in",
2504                            &body,
2505                            sent_at,
2506                        );
2507                        let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2508                        self.maybe_emit_mention(room_id, &body);
2509                        let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2510                            room_id: room_id.to_string(),
2511                            sender_fingerprint,
2512                            body,
2513                            sent_at,
2514                        });
2515                    }
2516                    Err(e) => {
2517                        debug!(%e, "decrypt failed (probably missing session key)");
2518                    }
2519                }
2520            }
2521            RoomMessage::Plain {
2522                sender_fingerprint,
2523                body,
2524            } => {
2525                if sender_fingerprint == our_fp {
2526                    return;
2527                }
2528                let sent_at = now_unix();
2529                let _ = repo::insert_room_message(
2530                    &self.db,
2531                    room_id,
2532                    &sender_fingerprint,
2533                    "in",
2534                    &body,
2535                    sent_at,
2536                );
2537                let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2538                self.maybe_emit_mention(room_id, &body);
2539                let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2540                    room_id: room_id.to_string(),
2541                    sender_fingerprint,
2542                    body,
2543                    sent_at,
2544                });
2545            }
2546            RoomMessage::Typing { sender_fingerprint } => {
2547                if sender_fingerprint == our_fp {
2548                    return;
2549                }
2550                let expiry = now_unix() + TYPING_TTL_SECS;
2551                let mut rooms = self.active_rooms.lock().unwrap();
2552                if let Some(room) = rooms.get_mut(room_id) {
2553                    room.typers.insert(sender_fingerprint, expiry);
2554                }
2555                drop(rooms);
2556                let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2557                    room_id: room_id.to_string(),
2558                });
2559            }
2560            RoomMessage::RotateRoomKey {
2561                rotator_fingerprint,
2562                new_salt,
2563            } => {
2564                if rotator_fingerprint == our_fp {
2565                    return;
2566                }
2567                // Rotations are self-attested: the signer must be the
2568                // claimed rotator. Unsigned forgeries land in
2569                // `verified_signer = None` and are dropped here, as are
2570                // signed envelopes where the signer fp doesn't match.
2571                let signer = match verified_signer {
2572                    Some(fp) => fp,
2573                    None => {
2574                        warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2575                        return;
2576                    }
2577                };
2578                if signer != rotator_fingerprint {
2579                    warn!(
2580                        %signer, %rotator_fingerprint, %room_id,
2581                        "RotateRoomKey signer mismatch with claimed rotator; dropping"
2582                    );
2583                    return;
2584                }
2585                let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2586                    room_id: room_id.to_string(),
2587                    rotator_fingerprint,
2588                    new_salt,
2589                });
2590            }
2591            RoomMessage::MemberLeave { sender_fingerprint } => {
2592                if sender_fingerprint == our_fp {
2593                    return;
2594                }
2595                let removed = {
2596                    let mut rooms = self.active_rooms.lock().unwrap();
2597                    if let Some(room) = rooms.get_mut(room_id) {
2598                        room.members.remove(&sender_fingerprint)
2599                    } else {
2600                        false
2601                    }
2602                };
2603                if removed {
2604                    let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2605                        room_id: room_id.to_string(),
2606                        fingerprint: sender_fingerprint,
2607                    });
2608                }
2609            }
2610            RoomMessage::FileOffer {
2611                sender_fingerprint,
2612                file_id,
2613                name,
2614                size_bytes,
2615                mime,
2616                chunk_count,
2617                encrypted_meta,
2618            } => {
2619                if sender_fingerprint == our_fp {
2620                    return; // ignore our own broadcast
2621                }
2622                self.handle_file_offer(
2623                    room_id,
2624                    sender_fingerprint,
2625                    file_id,
2626                    name,
2627                    size_bytes,
2628                    mime,
2629                    chunk_count,
2630                    encrypted_meta,
2631                );
2632            }
2633            RoomMessage::FileChunk {
2634                sender_fingerprint,
2635                file_id,
2636                chunk_index,
2637                total_chunks,
2638                data_b64,
2639            } => {
2640                if sender_fingerprint == our_fp {
2641                    return;
2642                }
2643                self.handle_file_chunk(
2644                    room_id,
2645                    sender_fingerprint,
2646                    file_id,
2647                    chunk_index,
2648                    total_chunks,
2649                    data_b64,
2650                );
2651            }
2652            RoomMessage::OwnerGrant {
2653                room_id: announced_room_id,
2654                target_fingerprint,
2655            } => {
2656                // Both: payload room_id must match the topic's room_id
2657                // (no cross-room replay), AND the signer must be a
2658                // current owner of this room. Unsigned forgeries land in
2659                // `verified_signer = None` and are dropped here.
2660                if announced_room_id != room_id {
2661                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2662                    return;
2663                }
2664                let signer = match verified_signer {
2665                    Some(fp) => fp,
2666                    None => {
2667                        warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2668                        return;
2669                    }
2670                };
2671                if !self.is_owner(room_id, &signer) {
2672                    warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2673                    return;
2674                }
2675                info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2676                if let Err(e) =
2677                    repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2678                {
2679                    warn!(%e, "OwnerGrant: set_member_role failed");
2680                }
2681            }
2682            RoomMessage::BanMember {
2683                room_id: announced_room_id,
2684                target_fingerprint,
2685            } => {
2686                if announced_room_id != room_id {
2687                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2688                    return;
2689                }
2690                let signer = match verified_signer {
2691                    Some(fp) => fp,
2692                    None => {
2693                        warn!(%room_id, "BanMember arrived unsigned; dropping");
2694                        return;
2695                    }
2696                };
2697                if !self.is_owner(room_id, &signer) {
2698                    warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2699                    return;
2700                }
2701                if target_fingerprint == our_fp {
2702                    // We've been kicked. Locally evict ourselves so the
2703                    // TUI tabs close; the kicker's subsequent
2704                    // RotateRoomKey will arrive separately and we
2705                    // simply won't be able to decrypt the new key,
2706                    // matching the "soft kick" semantics.
2707                    info!(%room_id, %signer, "we were kicked from this room");
2708                    self.active_rooms.lock().unwrap().remove(room_id);
2709                    let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2710                        room_id: room_id.to_string(),
2711                    });
2712                    return;
2713                }
2714                info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2715                if let Err(e) = repo::add_room_ban(
2716                    &self.db,
2717                    room_id,
2718                    &target_fingerprint,
2719                    &signer,
2720                    "", // signature lives in the envelope, not the row
2721                    now_unix(),
2722                ) {
2723                    warn!(%e, "BanMember: add_room_ban failed");
2724                }
2725                self.evict_banned_member(room_id, &target_fingerprint);
2726            }
2727            RoomMessage::SasInit {
2728                tx_id,
2729                ephemeral_x25519_pubkey_b64,
2730                target_fingerprint,
2731            } => {
2732                if target_fingerprint != our_fp {
2733                    // Not addressed to us — ignore. Phase G is point-
2734                    // to-point even though it travels over the room
2735                    // topic, so members of the room who aren't the
2736                    // target don't need to act.
2737                    return;
2738                }
2739                let signer = match verified_signer {
2740                    Some(fp) => fp,
2741                    None => {
2742                        warn!("SasInit arrived unsigned; dropping");
2743                        return;
2744                    }
2745                };
2746                let their_pub =
2747                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2748                        Ok(pk) => pk,
2749                        Err(e) => {
2750                            warn!(%e, "SasInit: bad x25519 pubkey");
2751                            return;
2752                        }
2753                    };
2754                let tx_id_bytes = match B64.decode(&tx_id) {
2755                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2756                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2757                        arr.copy_from_slice(&b);
2758                        arr
2759                    }
2760                    _ => {
2761                        warn!(%tx_id, "SasInit: bad tx_id length");
2762                        return;
2763                    }
2764                };
2765                let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2766                let sas_code =
2767                    crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2768                self.sas_flows.lock().unwrap().insert(
2769                    tx_id.clone(),
2770                    SasFlow {
2771                        room_id: room_id.to_string(),
2772                        partner_fingerprint: signer.clone(),
2773                        our_secret,
2774                        sas_code: Some(sas_code.clone()),
2775                        our_confirmed: false,
2776                        their_confirmed: false,
2777                    },
2778                );
2779                // Respond with our pubkey so the initiator can compute
2780                // the same code.
2781                let response = RoomMessage::SasResponse {
2782                    tx_id: tx_id.clone(),
2783                    ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2784                };
2785                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2786                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2787                        self.network
2788                            .publish_room_message(room_id.to_string(), bytes)
2789                            .await;
2790                    }
2791                }
2792                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2793                    room_id: room_id.to_string(),
2794                    partner_fingerprint: signer,
2795                    tx_id,
2796                    emoji_string: sas_code.emoji_string(),
2797                    emoji_labels: sas_code.emoji_labels(),
2798                    decimal: sas_code.decimal,
2799                });
2800            }
2801            RoomMessage::SasResponse {
2802                tx_id,
2803                ephemeral_x25519_pubkey_b64,
2804            } => {
2805                let signer = match verified_signer {
2806                    Some(fp) => fp,
2807                    None => {
2808                        warn!("SasResponse arrived unsigned; dropping");
2809                        return;
2810                    }
2811                };
2812                let their_pub =
2813                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2814                        Ok(pk) => pk,
2815                        Err(e) => {
2816                            warn!(%e, "SasResponse: bad x25519 pubkey");
2817                            return;
2818                        }
2819                    };
2820                let tx_id_bytes = match B64.decode(&tx_id) {
2821                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2822                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2823                        arr.copy_from_slice(&b);
2824                        arr
2825                    }
2826                    _ => return,
2827                };
2828                let emit = {
2829                    let mut flows = self.sas_flows.lock().unwrap();
2830                    let flow = match flows.get_mut(&tx_id) {
2831                        Some(f) => f,
2832                        None => {
2833                            warn!(%tx_id, "SasResponse for unknown tx_id");
2834                            return;
2835                        }
2836                    };
2837                    if flow.partner_fingerprint != signer {
2838                        warn!(
2839                            expected = %flow.partner_fingerprint, got = %signer,
2840                            "SasResponse signer doesn't match flow's partner; dropping"
2841                        );
2842                        return;
2843                    }
2844                    let code = crate::crypto::sas::derive_sas_code(
2845                        &flow.our_secret,
2846                        &their_pub,
2847                        &tx_id_bytes,
2848                    );
2849                    flow.sas_code = Some(code.clone());
2850                    code
2851                };
2852                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2853                    room_id: room_id.to_string(),
2854                    partner_fingerprint: signer,
2855                    tx_id,
2856                    emoji_string: emit.emoji_string(),
2857                    emoji_labels: emit.emoji_labels(),
2858                    decimal: emit.decimal,
2859                });
2860            }
2861            RoomMessage::CodeJoinRequest {
2862                room_id: announced_room_id,
2863                joiner_x25519_pubkey_b64,
2864                code,
2865            } => {
2866                if announced_room_id != room_id {
2867                    return;
2868                }
2869                let joiner_fp = match verified_signer {
2870                    Some(fp) => fp,
2871                    None => {
2872                        warn!("CodeJoinRequest unsigned; dropping");
2873                        return;
2874                    }
2875                };
2876                // Only owners with an active code are interested in
2877                // responding. Other peers (incl. non-issuing owners)
2878                // simply ignore.
2879                let our_fp = self.identity.fingerprint().to_string();
2880                if !self.is_owner(room_id, &our_fp) {
2881                    return;
2882                }
2883                // Match + consume the code. Single use.
2884                let now = now_unix();
2885                let (code_ok, our_session_id, wrap_input) = {
2886                    let mut rooms = self.active_rooms.lock().unwrap();
2887                    let room = match rooms.get_mut(room_id) {
2888                        Some(r) => r,
2889                        None => return,
2890                    };
2891                    if room.passphrase_key.is_none() {
2892                        warn!("CodeJoinRequest: no passphrase key locally; can't respond");
2893                        return;
2894                    }
2895                    let original_len = room.issued_codes.len();
2896                    room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
2897                    let matched = room.issued_codes.len() < original_len;
2898                    if !matched {
2899                        info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
2900                        return;
2901                    }
2902                    let crypto = room.crypto.as_ref().unwrap();
2903                    (
2904                        true,
2905                        crypto.our_session_id(),
2906                        crypto.our_session_key_b64(),
2907                    )
2908                };
2909                let _ = code_ok;
2910                // ECDH with the joiner's ephemeral pubkey.
2911                let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
2912                    Ok(pk) => pk,
2913                    Err(e) => {
2914                        warn!(%e, "CodeJoinRequest: bad pubkey");
2915                        return;
2916                    }
2917                };
2918                use x25519_dalek::{PublicKey, StaticSecret};
2919                let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
2920                let our_pub = PublicKey::from(&our_secret);
2921                let shared = our_secret.diffie_hellman(&their_pub);
2922                // HKDF the shared secret into a 32-byte wrap key.
2923                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2924                let mut wrap_key = [0u8; passphrase::KEY_LEN];
2925                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2926                    .expect("32 bytes is within HKDF limits");
2927                // Wrap our session key under the ECDH-derived key,
2928                // reusing the existing AEAD primitives.
2929                let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
2930                    Ok(w) => w,
2931                    Err(e) => {
2932                        warn!(%e, "CodeJoinRequest: wrap failed");
2933                        return;
2934                    }
2935                };
2936                let response = RoomMessage::CodeJoinResponse {
2937                    room_id: room_id.to_string(),
2938                    target_fingerprint: joiner_fp.clone(),
2939                    owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2940                    owner_session_id: our_session_id,
2941                    wrapped_session_key_b64: wrapped,
2942                    nonce_b64: String::new(), // nonce is embedded in `wrapped` per passphrase::wrap
2943                };
2944                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2945                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2946                        self.network
2947                            .publish_room_message(room_id.to_string(), bytes)
2948                            .await;
2949                    }
2950                }
2951                info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
2952            }
2953            RoomMessage::CodeJoinResponse {
2954                room_id: announced_room_id,
2955                target_fingerprint,
2956                owner_x25519_pubkey_b64,
2957                owner_session_id,
2958                wrapped_session_key_b64,
2959                nonce_b64: _,
2960            } => {
2961                if announced_room_id != room_id || target_fingerprint != our_fp {
2962                    return;
2963                }
2964                let owner_fp = match verified_signer {
2965                    Some(fp) => fp,
2966                    None => {
2967                        warn!("CodeJoinResponse unsigned; dropping");
2968                        return;
2969                    }
2970                };
2971                let our_secret = match self
2972                    .pending_code_secrets
2973                    .lock()
2974                    .unwrap()
2975                    .remove(&(room_id.to_string(), our_fp.clone()))
2976                {
2977                    Some(s) => s,
2978                    None => {
2979                        warn!(%room_id, "CodeJoinResponse with no pending code-join state");
2980                        return;
2981                    }
2982                };
2983                let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
2984                    Ok(pk) => pk,
2985                    Err(e) => {
2986                        warn!(%e, "CodeJoinResponse: bad owner pubkey");
2987                        return;
2988                    }
2989                };
2990                let shared = our_secret.diffie_hellman(&owner_pub);
2991                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
2992                let mut wrap_key = [0u8; passphrase::KEY_LEN];
2993                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
2994                    .expect("32 bytes within HKDF limits");
2995                let session_key_bytes =
2996                    match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
2997                        Ok(b) => b,
2998                        Err(e) => {
2999                            warn!(%e, "CodeJoinResponse: unwrap failed");
3000                            return;
3001                        }
3002                    };
3003                let session_key_str = match String::from_utf8(session_key_bytes) {
3004                    Ok(s) => s,
3005                    Err(e) => {
3006                        warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3007                        return;
3008                    }
3009                };
3010                // Install as an inbound session keyed by the owner's fp.
3011                let mut rooms = self.active_rooms.lock().unwrap();
3012                if let Some(room) = rooms.get_mut(room_id) {
3013                    if let Some(crypto) = room.crypto.as_mut() {
3014                        if let Err(e) =
3015                            crypto.add_inbound_session(&owner_fp, &session_key_str)
3016                        {
3017                            warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3018                        } else {
3019                            info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3020                            room.members.insert(owner_fp.clone());
3021                            let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3022                                room_id: room_id.to_string(),
3023                                fingerprint: owner_fp,
3024                            });
3025                        }
3026                    }
3027                }
3028            }
3029            RoomMessage::JoinRefused {
3030                room_id: announced_room_id,
3031                target_fingerprint,
3032                reason,
3033            } => {
3034                if announced_room_id != room_id || target_fingerprint != our_fp {
3035                    return;
3036                }
3037                // Surface the refusal as an Error so the user sees why
3038                // their join didn't take. The Phase 3 modal-queue rule
3039                // means this won't clobber typing in another modal.
3040                let _ = self.app_event_tx.send(AppEvent::Error {
3041                    description: format!("join refused: {reason}"),
3042                });
3043            }
3044            RoomMessage::SasConfirm { tx_id, matched } => {
3045                let signer = match verified_signer {
3046                    Some(fp) => fp,
3047                    None => return,
3048                };
3049                let (room_id_done, partner_fp_done, both_done) = {
3050                    let mut flows = self.sas_flows.lock().unwrap();
3051                    let flow = match flows.get_mut(&tx_id) {
3052                        Some(f) => f,
3053                        None => return,
3054                    };
3055                    if flow.partner_fingerprint != signer {
3056                        return;
3057                    }
3058                    if !matched {
3059                        // Partner declined / mismatch — drop the flow.
3060                        let _ = flow;
3061                        flows.remove(&tx_id);
3062                        return;
3063                    }
3064                    flow.their_confirmed = true;
3065                    if flow.our_confirmed && flow.their_confirmed {
3066                        (
3067                            Some(flow.room_id.clone()),
3068                            Some(flow.partner_fingerprint.clone()),
3069                            true,
3070                        )
3071                    } else {
3072                        (None, None, false)
3073                    }
3074                };
3075                if both_done {
3076                    if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3077                        if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3078                            warn!(%e, "finish_sas failed");
3079                        }
3080                    }
3081                }
3082            }
3083            RoomMessage::ProfileUpdate {
3084                sender_fingerprint,
3085                username,
3086                updated_at,
3087            } => {
3088                // huddle 0.5: username spoof defense. Drop any
3089                // ProfileUpdate that didn't arrive inside a Signed
3090                // envelope, or whose signer doesn't match the claimed
3091                // sender_fingerprint. Without this anyone could pretend
3092                // to be "alice" by stuffing the field.
3093                let signer = match verified_signer {
3094                    Some(fp) => fp,
3095                    None => {
3096                        warn!(
3097                            sender = %sender_fingerprint,
3098                            "dropping unsigned ProfileUpdate"
3099                        );
3100                        return;
3101                    }
3102                };
3103                if signer != sender_fingerprint {
3104                    warn!(
3105                        signer = %signer,
3106                        claimed = %sender_fingerprint,
3107                        "dropping ProfileUpdate with signer != sender"
3108                    );
3109                    return;
3110                }
3111                if let Err(e) = repo::upsert_peer_profile(
3112                    &self.db,
3113                    &sender_fingerprint,
3114                    username.as_deref(),
3115                    updated_at,
3116                ) {
3117                    warn!(%e, "upsert_peer_profile failed");
3118                    return;
3119                }
3120                let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3121                    fingerprint: sender_fingerprint,
3122                    username,
3123                });
3124            }
3125        }
3126    }
3127
3128    // -------------------------------------------------------------------
3129    // File transfer — public API
3130    // -------------------------------------------------------------------
3131
3132    /// Send a local file to a room. Reads the file, optionally encrypts
3133    /// it for encrypted rooms, chunks it, broadcasts a FileOffer then
3134    /// each FileChunk. Returns the file_id once all chunks are queued.
3135    pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3136        let bytes = std::fs::read(path)?;
3137        let name = path
3138            .file_name()
3139            .map(|n| n.to_string_lossy().to_string())
3140            .unwrap_or_else(|| "untitled".into());
3141        let mime = crate::files::guess_mime(&name);
3142        let original_path = path.to_path_buf();
3143
3144        let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3145            let mut rooms = self.active_rooms.lock().unwrap();
3146            let room = rooms
3147                .get_mut(room_id)
3148                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3149            if room.info.encrypted {
3150                let crypto = room
3151                    .crypto
3152                    .as_mut()
3153                    .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3154                let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3155                (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3156            } else {
3157                (false, None, None, bytes)
3158            }
3159        };
3160        let _ = &mut maybe_session_id; // silence unused warning when non-encrypted
3161
3162        let plan =
3163            self.file_manager
3164                .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3165        let file_id = plan.file_id.clone();
3166        let total = plan.chunks.len() as u32;
3167        let our_fp = self.identity.fingerprint().to_string();
3168
3169        let attachment = StoredAttachment {
3170            id: 0,
3171            room_id: room_id.to_string(),
3172            message_id: None,
3173            sender_fingerprint: our_fp.clone(),
3174            file_id: file_id.clone(),
3175            name: name.clone(),
3176            mime: mime.clone(),
3177            size_bytes: plan.size_bytes as i64,
3178            status: AttachmentStatus::Ready,
3179            cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3180            saved_path: Some(original_path.to_string_lossy().into()),
3181            error: None,
3182            encrypted: room_encrypted,
3183            wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3184            nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3185            megolm_session_id: encrypted_meta_opt
3186                .as_ref()
3187                .map(|m| m.megolm_session_id.clone()),
3188            content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3189            created_at: now_unix(),
3190        };
3191        repo::upsert_attachment(&self.db, &attachment)?;
3192        let _ = self.app_event_tx.send(AppEvent::FileOffered {
3193            room_id: room_id.to_string(),
3194            file_id: file_id.clone(),
3195            name: name.clone(),
3196            size_bytes: plan.size_bytes,
3197            sender_fingerprint: our_fp.clone(),
3198        });
3199
3200        // Publish the offer.
3201        let offer = RoomMessage::FileOffer {
3202            sender_fingerprint: our_fp.clone(),
3203            file_id: file_id.clone(),
3204            name,
3205            size_bytes: plan.size_bytes,
3206            mime,
3207            chunk_count: total,
3208            encrypted_meta: encrypted_meta_opt,
3209        };
3210        if let Ok(bytes) = encode_wire(&offer) {
3211            self.network
3212                .publish_room_message(room_id.to_string(), bytes)
3213                .await;
3214        }
3215
3216        // Stream chunks. Brief pacing so gossipsub doesn't see a thundering
3217        // herd from a single peer.
3218        let net = self.network.clone();
3219        let room = room_id.to_string();
3220        let our = our_fp.clone();
3221        let fid = file_id.clone();
3222        let chunks = plan.chunks.clone();
3223        tokio::spawn(async move {
3224            for (i, data) in chunks.iter().enumerate() {
3225                let msg = RoomMessage::FileChunk {
3226                    sender_fingerprint: our.clone(),
3227                    file_id: fid.clone(),
3228                    chunk_index: i as u32,
3229                    total_chunks: total,
3230                    data_b64: B64.encode(data),
3231                };
3232                if let Ok(bytes) = encode_wire(&msg) {
3233                    net.publish_room_message(room.clone(), bytes).await;
3234                }
3235                tokio::time::sleep(Duration::from_millis(40)).await;
3236            }
3237        });
3238
3239        Ok(file_id)
3240    }
3241
3242    /// Save a completed/ready attachment to the user's Downloads folder.
3243    /// Decrypts encrypted attachments on the way out.
3244    pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3245        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3246            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3247        if !matches!(
3248            attachment.status,
3249            AttachmentStatus::Ready | AttachmentStatus::Saved
3250        ) {
3251            return Err(HuddleError::Other(format!(
3252                "attachment is not ready (status={})",
3253                attachment.status.as_str()
3254            )));
3255        }
3256        // Our own encrypted attachment: the file_manager cache holds the
3257        // ciphertext and we have no inbound Megolm session keyed by
3258        // ourselves, so it can't be decrypted back. But `saved_path` still
3259        // points at the original plaintext we sent — copy from there.
3260        let plaintext = if attachment.encrypted
3261            && attachment.sender_fingerprint == self.identity.fingerprint()
3262        {
3263            match attachment
3264                .saved_path
3265                .as_deref()
3266                .filter(|p| Path::new(p).exists())
3267            {
3268                Some(src) => std::fs::read(src)?,
3269                None => {
3270                    return Err(HuddleError::Other(
3271                        "your original file has moved or been deleted — it can't be \
3272                         recovered from the encrypted cache"
3273                            .into(),
3274                    ));
3275                }
3276            }
3277        } else {
3278            let cached = self.file_manager.read_cache(file_id)?;
3279            if attachment.encrypted {
3280                let meta = EncryptedFileMeta {
3281                    megolm_session_id: attachment
3282                        .megolm_session_id
3283                        .clone()
3284                        .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3285                    wrapped_key_b64: attachment
3286                        .wrapped_key
3287                        .clone()
3288                        .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3289                    nonce_b64: attachment
3290                        .nonce
3291                        .clone()
3292                        .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3293                    content_hash: attachment
3294                        .content_hash
3295                        .clone()
3296                        .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3297                };
3298                self.decrypt_attachment(
3299                    room_id,
3300                    &attachment.sender_fingerprint,
3301                    &cached,
3302                    &meta,
3303                )?
3304            } else {
3305                cached
3306            }
3307        };
3308        let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3309        repo::update_attachment_paths(
3310            &self.db,
3311            room_id,
3312            file_id,
3313            None,
3314            Some(&saved.to_string_lossy()),
3315        )?;
3316        repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3317        let _ = self.app_event_tx.send(AppEvent::FileSaved {
3318            file_id: file_id.into(),
3319            path: saved.to_string_lossy().into(),
3320        });
3321        Ok(saved)
3322    }
3323
3324    /// Drop any in-flight chunks and remove the attachment row.
3325    pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3326        self.file_manager.cancel_incoming(file_id);
3327        repo::update_attachment_status(
3328            &self.db,
3329            room_id,
3330            file_id,
3331            AttachmentStatus::Cancelled,
3332            None,
3333        )?;
3334        Ok(())
3335    }
3336
3337    /// Launch the system's default opener on a saved file.
3338    pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3339        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3340            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3341        let path = attachment
3342            .saved_path
3343            .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3344        open_with_system(&path)
3345    }
3346
3347    pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3348        repo::list_room_attachments(&self.db, room_id)
3349    }
3350
3351    /// Mark a peer's fingerprint as verified in the given room. Used by
3352    /// the `^V` verification modal after the user has compared the
3353    /// fingerprint out-of-band.
3354    pub fn set_member_verified(
3355        &self,
3356        room_id: &str,
3357        fingerprint: &str,
3358        verified: bool,
3359    ) -> Result<()> {
3360        // Make sure there's a member row to flip — peer_id is unknown
3361        // at this layer when the user verifies an out-of-band identity,
3362        // so we use the fingerprint as the canonical identity key with
3363        // an empty peer_id placeholder if none exists.
3364        let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3365        if !members.iter().any(|m| m.fingerprint == fingerprint) {
3366            repo::upsert_room_member(
3367                &self.db,
3368                &StoredRoomMember {
3369                    room_id: room_id.to_string(),
3370                    peer_id: String::new(),
3371                    fingerprint: fingerprint.to_string(),
3372                    last_seen: Some(now_unix()),
3373                    verified,
3374                    ed25519_pubkey: None,
3375                    role: "member".into(),
3376                },
3377            )?;
3378        }
3379        repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3380    }
3381
3382    pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3383        repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3384    }
3385
3386    /// Phase B: is `fingerprint` an owner of `room_id`? Used by the TUI
3387    /// to gate `^K` / `^G` and the kick/grant member-picker actions.
3388    pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3389        repo::list_room_owners(&self.db, room_id)
3390            .unwrap_or_default()
3391            .iter()
3392            .any(|fp| fp == fingerprint)
3393    }
3394
3395    pub fn we_are_owner(&self, room_id: &str) -> bool {
3396        self.is_owner(room_id, &self.identity.fingerprint().to_string())
3397    }
3398
3399    /// Phase B: list current owner fingerprints for `room_id` — used to
3400    /// render an owner badge in the member panel.
3401    pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3402        repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3403    }
3404
3405    /// huddle 0.7.6: true iff this session was started with a master
3406    /// passphrase. The TUI uses this to pick the Go Dark gate — passphrase
3407    /// if available (the natural strong secret the user already knows),
3408    /// else the typed `DELETE EVERYTHING` phrase since no-master-passphrase
3409    /// sessions have nothing else to compare against.
3410    pub fn has_master_passphrase(&self) -> bool {
3411        self.session_persist_key != [0u8; 32]
3412    }
3413
3414    /// Phase E: global toggle — when true, inbound dials from
3415    /// unverified fingerprints are auto-rejected without prompting.
3416    pub fn verified_only_inbound(&self) -> bool {
3417        repo::get_setting(&self.db, "verified_only_inbound")
3418            .unwrap_or(None)
3419            .map(|v| v == "1")
3420            .unwrap_or(false)
3421    }
3422
3423    pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3424        repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3425    }
3426
3427    /// Phase E: per-room verified-only-join. When true, the host (and
3428    /// every honest existing member) drops MemberAnnounce from joiners
3429    /// who aren't globally SAS-verified, and the lowest-fp owner sends
3430    /// back a signed `JoinRefused` so the joiner sees an explanation.
3431    pub fn room_verified_only(&self, room_id: &str) -> bool {
3432        repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3433    }
3434
3435    pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3436        repo::set_room_verified_only(&self.db, room_id, on)
3437    }
3438
3439    /// Phase H: first-launch onboarding flag.
3440    pub fn onboarding_seen(&self) -> bool {
3441        repo::is_onboarding_seen(&self.db).unwrap_or(true)
3442    }
3443
3444    pub fn mark_onboarding_seen(&self) -> Result<()> {
3445        repo::mark_onboarding_seen(&self.db)
3446    }
3447
3448    /// huddle 0.6: version string of huddle the user last finished
3449    /// onboarding for. Compared against `env!("CARGO_PKG_VERSION")` at
3450    /// startup so a version bump re-fires the "what's new" card.
3451    pub fn last_seen_onboarding_version(&self) -> Option<String> {
3452        repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3453    }
3454
3455    pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3456        repo::set_last_seen_onboarding_version(&self.db, version)
3457    }
3458
3459    /// huddle 0.6: opt-in flag for the crates.io update check.
3460    /// `None` ⇒ the user hasn't been asked yet.
3461    pub fn update_check_enabled(&self) -> Option<bool> {
3462        repo::get_update_check_enabled(&self.db).unwrap_or(None)
3463    }
3464
3465    pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3466        repo::set_update_check_enabled(&self.db, enabled)
3467    }
3468
3469    /// huddle 0.6: cache anchor for the once-per-24h crates.io poll.
3470    /// Returns 0 if nothing has been recorded yet.
3471    pub fn last_update_check_at(&self) -> i64 {
3472        repo::get_setting(&self.db, "last_update_check_at")
3473            .ok()
3474            .flatten()
3475            .and_then(|s| s.parse().ok())
3476            .unwrap_or(0)
3477    }
3478
3479    pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3480        repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3481    }
3482
3483    /// huddle 0.6: the most recent `max_stable_version` we saw on
3484    /// crates.io. Persisted so a re-launch within the 24h window
3485    /// can render the banner without re-fetching.
3486    pub fn last_known_remote_version(&self) -> Option<String> {
3487        repo::get_setting(&self.db, "last_known_remote_version")
3488            .ok()
3489            .flatten()
3490    }
3491
3492    pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3493        repo::set_setting(&self.db, "last_known_remote_version", v)
3494    }
3495
3496    /// Phase B: promote `target_fingerprint` to owner. Builds a signed
3497    /// `OwnerGrant`, broadcasts it, and applies it locally. Returns an
3498    /// error if we ourselves aren't an owner — only owners can grant.
3499    pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3500        let our_fp = self.identity.fingerprint().to_string();
3501        if !self.is_owner(room_id, &our_fp) {
3502            return Err(HuddleError::Other(
3503                "only an owner can grant owner".into(),
3504            ));
3505        }
3506        let msg = RoomMessage::OwnerGrant {
3507            room_id: room_id.to_string(),
3508            target_fingerprint: target_fingerprint.to_string(),
3509        };
3510        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3511        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3512        self.network
3513            .publish_room_message(room_id.to_string(), bytes)
3514            .await;
3515        // Apply locally too — peers will converge on the next announce.
3516        repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3517        Ok(())
3518    }
3519
3520    /// Phase B: kick `target_fingerprint` from `room_id`. Broadcasts a
3521    /// signed `BanMember`, records the ban locally, then immediately
3522    /// rotates the room key under a freshly-generated passphrase. Returns
3523    /// the new passphrase so the caller can show it to the owner for
3524    /// out-of-band sharing with remaining members.
3525    ///
3526    /// The rotation is the cryptographic enforcement: a banned peer can
3527    /// still subscribe to the gossipsub topic and see the ciphertext,
3528    /// but they can't unwrap the new session key without the new
3529    /// passphrase, so they can't decrypt anything sent after the kick.
3530    pub async fn kick_member(
3531        &self,
3532        room_id: &str,
3533        target_fingerprint: &str,
3534    ) -> Result<String> {
3535        let our_fp = self.identity.fingerprint().to_string();
3536        if !self.is_owner(room_id, &our_fp) {
3537            return Err(HuddleError::Other("only an owner can kick".into()));
3538        }
3539        if target_fingerprint == our_fp {
3540            return Err(HuddleError::Other("can't kick yourself".into()));
3541        }
3542        let info = self
3543            .active_rooms
3544            .lock()
3545            .unwrap()
3546            .get(room_id)
3547            .map(|r| r.info.clone())
3548            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3549        if !info.encrypted {
3550            // Without a key to rotate, a "kick" is purely advisory —
3551            // ban only. Honest clients drop their messages, but anyone
3552            // can still read the room. Honest in v1; documented.
3553            let msg = RoomMessage::BanMember {
3554                room_id: room_id.to_string(),
3555                target_fingerprint: target_fingerprint.to_string(),
3556            };
3557            let env = crate::crypto::sign_message(&self.identity, &msg)?;
3558            let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3559            self.network
3560                .publish_room_message(room_id.to_string(), bytes)
3561                .await;
3562            repo::add_room_ban(
3563                &self.db,
3564                room_id,
3565                target_fingerprint,
3566                &our_fp,
3567                &env.signature_b64,
3568                now_unix(),
3569            )?;
3570            self.evict_banned_member(room_id, target_fingerprint);
3571            return Ok(String::new());
3572        }
3573        // Encrypted room — full kick path.
3574        let new_passphrase = generate_join_passphrase();
3575        let msg = RoomMessage::BanMember {
3576            room_id: room_id.to_string(),
3577            target_fingerprint: target_fingerprint.to_string(),
3578        };
3579        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3580        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3581        self.network
3582            .publish_room_message(room_id.to_string(), bytes)
3583            .await;
3584        repo::add_room_ban(
3585            &self.db,
3586            room_id,
3587            target_fingerprint,
3588            &our_fp,
3589            &env.signature_b64,
3590            now_unix(),
3591        )?;
3592        self.evict_banned_member(room_id, target_fingerprint);
3593        // Reuse the existing rotation flow so all the existing salt /
3594        // session / persistence logic stays in one place.
3595        self.rotate_room(room_id, &new_passphrase).await?;
3596        Ok(new_passphrase)
3597    }
3598
3599    /// Phase F: generate an 8-char alphanumeric join code for `room_id`,
3600    /// good for 10 minutes. Stored in memory only on the issuing owner's
3601    /// machine — a single use clears it. Caller is responsible for
3602    /// sharing the code OOB with the prospective joiner.
3603    ///
3604    /// Owner-only. Errors if `room_id` isn't active or we're not an owner.
3605    pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3606        let our_fp = self.identity.fingerprint().to_string();
3607        if !self.is_owner(room_id, &our_fp) {
3608            return Err(HuddleError::Other(
3609                "only an owner can issue join codes".into(),
3610            ));
3611        }
3612        let code = generate_alphanumeric_code(8);
3613        let expires_at = now_unix() + 10 * 60;
3614        let mut rooms = self.active_rooms.lock().unwrap();
3615        let room = rooms
3616            .get_mut(room_id)
3617            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3618        // Prune expired entries while we're here so the list doesn't grow.
3619        let now = now_unix();
3620        room.issued_codes.retain(|(_, exp)| *exp > now);
3621        room.issued_codes.push((code.clone(), expires_at));
3622        Ok(code)
3623    }
3624
3625    /// Phase F: join `room_id` using a short-lived code instead of the
3626    /// passphrase. Generates an ephemeral X25519 keypair, broadcasts a
3627    /// signed `CodeJoinRequest`, and waits for the owner's
3628    /// `CodeJoinResponse`. The receive arm builds an `ActiveRoom`
3629    /// flagged read-only (no passphrase = can't share our outbound
3630    /// session key with others).
3631    pub async fn join_room_with_code(
3632        &self,
3633        room_id: &str,
3634        code: &str,
3635    ) -> Result<()> {
3636        // Resolve discovered metadata so we know name/encrypted/etc.
3637        let info = {
3638            let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3639            match d {
3640                Some(d) => StoredRoom {
3641                    id: room_id.to_string(),
3642                    name: d.name,
3643                    creator_fingerprint: d.creator_fingerprint,
3644                    encrypted: d.encrypted,
3645                    passphrase_salt: None, // unused on code-join path
3646                    created_at: now_unix(),
3647                    last_active: Some(now_unix()),
3648                    // huddle 0.7: code-join is groups-only by design — DMs
3649                    // are 1-1 and don't use the code flow.
3650                    kind: d.kind,
3651                },
3652                None => {
3653                    return Err(HuddleError::Other(format!(
3654                        "room {room_id} not visible — wait for an announcement"
3655                    )))
3656                }
3657            }
3658        };
3659        if !info.encrypted {
3660            return Err(HuddleError::Other(
3661                "code-join only applies to encrypted rooms".into(),
3662            ));
3663        }
3664        let our_fp = self.identity.fingerprint().to_string();
3665        // Generate ephemeral X25519 keypair; remember the secret so the
3666        // CodeJoinResponse receive arm can complete ECDH on this peer.
3667        use x25519_dalek::{PublicKey, StaticSecret};
3668        let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3669        let our_pub = PublicKey::from(&our_secret);
3670        // Stash the secret keyed by (room_id, our_fp); the response
3671        // handler removes the matching entry when a response targeted
3672        // at us arrives. The composite key means a second joiner can
3673        // be in flight in the same room without overwriting our state.
3674        let key = (room_id.to_string(), our_fp.clone());
3675        self.pending_code_secrets
3676            .lock()
3677            .unwrap()
3678            .insert(key.clone(), our_secret);
3679        // Code-join timeout: if no response in 30s, the entry will
3680        // still be in the map (the response handler removes it on
3681        // success). Surface a `CodeJoinTimedOut` to the TUI so the
3682        // user isn't stuck staring at an empty room expecting traffic.
3683        let map = self.pending_code_secrets.clone();
3684        let tx = self.app_event_tx.clone();
3685        let timeout_room = room_id.to_string();
3686        tokio::spawn(async move {
3687            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3688            let still_pending = map.lock().unwrap().remove(&key).is_some();
3689            if still_pending {
3690                let _ = tx.send(AppEvent::CodeJoinTimedOut {
3691                    room_id: timeout_room,
3692                    reason: "no response from owner — code may be wrong or expired".into(),
3693                });
3694            }
3695        });
3696        // Persist the rooms row BEFORE constructing RoomCrypto, whose
3697        // `persist_outbound()` writes a `room_megolm_sessions` row with
3698        // a FK to `rooms(id)`. Without this, the FK fires and the
3699        // join aborts. The salt is left None for now — we don't have
3700        // the passphrase and the announcing peer's salt is cached in
3701        // ROOM_SALT_CACHE for whenever we get re-onboarded.
3702        repo::insert_room(&self.db, &info)?;
3703        // Create a placeholder ActiveRoom with no crypto yet; we'll
3704        // fill in the inbound session in the response handler.
3705        self.active_rooms.lock().unwrap().insert(
3706            room_id.to_string(),
3707            ActiveRoom {
3708                info: info.clone(),
3709                crypto: Some(RoomCrypto::new_for_room(
3710                    self.db.clone(),
3711                    room_id.to_string(),
3712                    our_fp.clone(),
3713                    self.session_persist_key,
3714                )?),
3715                passphrase_key: None,
3716                members: {
3717                    let mut s = HashSet::new();
3718                    s.insert(our_fp.clone());
3719                    s
3720                },
3721                typers: HashMap::new(),
3722                read_only: true,
3723                issued_codes: Vec::new(),
3724            },
3725        );
3726        self.network.subscribe_room(room_id.to_string()).await;
3727        // Broadcast the request.
3728        let req = RoomMessage::CodeJoinRequest {
3729            room_id: room_id.to_string(),
3730            joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3731            code: code.to_string(),
3732        };
3733        let env = crate::crypto::sign_message(&self.identity, &req)?;
3734        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3735        self.network
3736            .publish_room_message(room_id.to_string(), bytes)
3737            .await;
3738        // Emit RoomJoined so the TUI opens the tab. Subsequent ability
3739        // to read messages depends on receiving the owner's response.
3740        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3741            room_id: room_id.to_string(),
3742        });
3743        Ok(())
3744    }
3745
3746    /// Phase G: start an SAS verification with `target_fingerprint` in
3747    /// `room_id`. Returns the tx_id so the caller can correlate
3748    /// subsequent events. The full flow is asynchronous — the partner
3749    /// must accept on their end, both compute the ECDH-derived SAS
3750    /// code, OOB-compare it, and each press Match.
3751    pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3752        let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3753        let tx_id = B64.encode(tx_id_bytes);
3754        let msg = RoomMessage::SasInit {
3755            tx_id: tx_id.clone(),
3756            ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3757            target_fingerprint: target_fingerprint.to_string(),
3758        };
3759        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3760        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3761        self.sas_flows.lock().unwrap().insert(
3762            tx_id.clone(),
3763            SasFlow {
3764                room_id: room_id.to_string(),
3765                partner_fingerprint: target_fingerprint.to_string(),
3766                our_secret,
3767                sas_code: None,
3768                our_confirmed: false,
3769                their_confirmed: false,
3770            },
3771        );
3772        self.network
3773            .publish_room_message(room_id.to_string(), bytes)
3774            .await;
3775        Ok(tx_id)
3776    }
3777
3778    /// Phase G: user pressed Match on the SAS code modal — broadcast our
3779    /// signed `SasConfirm{matched: true}`. If the partner has already
3780    /// matched, this completes verification on both sides.
3781    pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
3782        let (room_id, partner_fp, both_done) = {
3783            let mut flows = self.sas_flows.lock().unwrap();
3784            let flow = flows
3785                .get_mut(tx_id)
3786                .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
3787            flow.our_confirmed = true;
3788            (
3789                flow.room_id.clone(),
3790                flow.partner_fingerprint.clone(),
3791                flow.our_confirmed && flow.their_confirmed,
3792            )
3793        };
3794        let msg = RoomMessage::SasConfirm {
3795            tx_id: tx_id.to_string(),
3796            matched: true,
3797        };
3798        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3799        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3800        self.network
3801            .publish_room_message(room_id.clone(), bytes)
3802            .await;
3803        if both_done {
3804            self.finish_sas(tx_id, &room_id, &partner_fp).await?;
3805        }
3806        Ok(())
3807    }
3808
3809    /// Phase G: cancel an in-flight SAS — drop our local state. Doesn't
3810    /// broadcast a "matched=false" notice in v1 (partner's flow stays
3811    /// dangling; they can cancel their side too). Quiet teardown.
3812    pub fn sas_cancel(&self, tx_id: &str) {
3813        self.sas_flows.lock().unwrap().remove(tx_id);
3814    }
3815
3816    /// Phase G internal: both sides have confirmed — flip the partner's
3817    /// fingerprint to verified (per-room AND global) and clean up.
3818    async fn finish_sas(
3819        &self,
3820        tx_id: &str,
3821        room_id: &str,
3822        partner_fingerprint: &str,
3823    ) -> Result<()> {
3824        repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
3825        repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
3826        self.sas_flows.lock().unwrap().remove(tx_id);
3827        let _ = self.app_event_tx.send(AppEvent::SasVerified {
3828            room_id: room_id.to_string(),
3829            partner_fingerprint: partner_fingerprint.to_string(),
3830        });
3831        Ok(())
3832    }
3833
3834    /// Phase B internal: drop a banned member's in-memory presence in a
3835    /// room. Persistent ban already went to `room_bans`. Called from
3836    /// `kick_member` (locally banning ourselves) and from the
3837    /// `RoomMessage::BanMember` receive arm (peer-initiated ban).
3838    fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
3839        if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
3840            room.members.remove(fingerprint);
3841        }
3842        let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3843            room_id: room_id.to_string(),
3844            fingerprint: fingerprint.to_string(),
3845        });
3846    }
3847
3848    pub fn display_name(&self) -> Option<String> {
3849        repo::get_display_name(&self.db).unwrap_or(None)
3850    }
3851
3852    pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
3853        repo::set_display_name(&self.db, name)
3854    }
3855
3856    /// huddle 0.5: set the local user's self-declared username (or clear
3857    /// it with None) and broadcast a signed `ProfileUpdate` to every
3858    /// joined room. Receivers cache the latest per-fingerprint username
3859    /// in `peer_profiles`; unsigned envelopes are dropped at the receive
3860    /// arm so the username can't be spoofed.
3861    pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
3862        repo::set_display_name(&self.db, name)?;
3863        let msg = RoomMessage::ProfileUpdate {
3864            sender_fingerprint: self.identity.fingerprint().to_string(),
3865            username: name.map(|s| s.to_string()),
3866            updated_at: now_unix_ms(),
3867        };
3868        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3869        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3870        let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
3871        for room_id in rooms {
3872            self.network
3873                .publish_room_message(room_id, bytes.clone())
3874                .await;
3875        }
3876        Ok(())
3877    }
3878
3879    /// huddle 0.5: cached username for a peer (any peer we've ever
3880    /// received a signed `ProfileUpdate` from), or None if unknown or
3881    /// the peer cleared their username. Callers render `[anonymous]` on
3882    /// None.
3883    pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
3884        repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
3885    }
3886
3887    /// Look up the display name we've seen for a peer. Forwards to
3888    /// `lookup_username` (the new signed-source-of-truth) so existing
3889    /// call sites get the authenticated value without churn.
3890    pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
3891        self.lookup_username(fingerprint)
3892    }
3893
3894    pub fn is_room_muted(&self, room_id: &str) -> bool {
3895        repo::is_room_muted(&self.db, room_id).unwrap_or(false)
3896    }
3897
3898    /// Phase B: list the fingerprints currently banned from a room
3899    /// (newest first). Backs the `^B` in-room view; intended for
3900    /// owners but the read itself is harmless and we let callers
3901    /// gate via `we_are_owner` if they want owner-only display.
3902    pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
3903        repo::list_room_bans(&self.db, room_id).unwrap_or_default()
3904    }
3905
3906    /// Phase A: list every globally-blocked peer (one fingerprint per
3907    /// row). Surfaced in the Settings modal alongside a clear-all
3908    /// action that calls `unblock_peer` in a loop.
3909    /// huddle 0.7: every globally SAS-verified peer. Surfaced in the
3910    /// People pane's "Verified" sub-list.
3911    pub fn list_verified_peers(&self) -> Vec<String> {
3912        repo::list_verified_peers(&self.db).unwrap_or_default()
3913    }
3914
3915    pub fn list_blocked_peers(&self) -> Vec<String> {
3916        repo::list_blocked_peers(&self.db).unwrap_or_default()
3917    }
3918
3919    /// Phase A: remove `fingerprint` from the persistent blocklist. The
3920    /// peer will no longer be auto-rejected on connection; they fall
3921    /// back to the regular inbound-dial accept/reject prompt.
3922    pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
3923        repo::unblock_peer(&self.db, fingerprint)
3924    }
3925
3926    /// huddle 0.7: add `fingerprint` to the persistent blocklist. Used
3927    /// by the People pane's per-row "block" action. Subsequent inbound
3928    /// dials from this fingerprint are auto-rejected without prompting.
3929    pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
3930        repo::block_peer(&self.db, fingerprint, now_unix())
3931    }
3932
3933    /// Phase F: rooms entered via a join code don't have the passphrase
3934    /// in memory, so the joining peer can't wrap their own outbound
3935    /// session key for newer members — they can read and send, they
3936    /// just can't onboard others. The TUI renders a `(read-only)`
3937    /// badge in the room tab so the user understands.
3938    pub fn is_room_read_only(&self, room_id: &str) -> bool {
3939        self.active_rooms
3940            .lock()
3941            .unwrap()
3942            .get(room_id)
3943            .map(|r| r.read_only)
3944            .unwrap_or(false)
3945    }
3946
3947    pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
3948        repo::set_room_muted(&self.db, room_id, muted)
3949    }
3950
3951    /// Broadcast a "I'm typing" pulse to the given room. Caller is
3952    /// responsible for debouncing (don't fire more than every ~500ms).
3953    pub async fn broadcast_typing(&self, room_id: &str) {
3954        if !self.active_rooms.lock().unwrap().contains_key(room_id) {
3955            return;
3956        }
3957        let msg = RoomMessage::Typing {
3958            sender_fingerprint: self.identity.fingerprint().to_string(),
3959        };
3960        if let Ok(bytes) = encode_wire(&msg) {
3961            self.network
3962                .publish_room_message(room_id.to_string(), bytes)
3963                .await;
3964        }
3965    }
3966
3967    /// Returns the fingerprints of peers currently typing in `room_id`,
3968    /// pruning entries past their TTL.
3969    pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
3970        let now = now_unix();
3971        let mut rooms = self.active_rooms.lock().unwrap();
3972        let room = match rooms.get_mut(room_id) {
3973            Some(r) => r,
3974            None => return Vec::new(),
3975        };
3976        room.typers.retain(|_, exp| *exp > now);
3977        let mut v: Vec<String> = room.typers.keys().cloned().collect();
3978        v.sort();
3979        v
3980    }
3981
3982    // -------------------------------------------------------------------
3983    // Room key rotation
3984    // -------------------------------------------------------------------
3985
3986    /// Rotate this room's outbound Megolm session under a fresh
3987    /// passphrase. Broadcasts `RotateRoomKey` (so other members know to
3988    /// expect a new passphrase) and a fresh `MemberAnnounce` with the
3989    /// new wrapped session key. Old inbound sessions stay in storage
3990    /// for decrypting historic messages.
3991    pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
3992        if new_passphrase.is_empty() {
3993            return Err(HuddleError::Other("new passphrase is empty".into()));
3994        }
3995        let new_salt = passphrase::random_salt();
3996        let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
3997
3998        let info = {
3999            let mut rooms = self.active_rooms.lock().unwrap();
4000            let room = rooms
4001                .get_mut(room_id)
4002                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4003            if !room.info.encrypted {
4004                return Err(HuddleError::Other(
4005                    "rotation only applies to encrypted rooms".into(),
4006                ));
4007            }
4008            // Generate a fresh outbound Megolm session for this member.
4009            let new_crypto = RoomCrypto::new_for_room(
4010                self.db.clone(),
4011                room_id.to_string(),
4012                self.identity.fingerprint().to_string(),
4013                self.session_persist_key,
4014            )?;
4015            room.crypto = Some(new_crypto);
4016            room.passphrase_key = Some(new_key);
4017            room.info.passphrase_salt = Some(new_salt.to_vec());
4018            room.info.clone()
4019        };
4020
4021        // Broadcast before persisting: peers learn about the rotation even
4022        // if we crash before the DB write lands, and our own restore path
4023        // can recover from the persisted Megolm session plus the announced
4024        // salt. Persisting first would risk a DB row that's ahead of what
4025        // any peer knows.
4026        let rot = RoomMessage::RotateRoomKey {
4027            rotator_fingerprint: self.identity.fingerprint().to_string(),
4028            new_salt: new_salt.to_vec(),
4029        };
4030        // Signed: rotations are self-attested, so peers can prove the
4031        // claimed `rotator_fingerprint` really came from that identity.
4032        // An unsigned rotation is rejected on the receive side.
4033        if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4034            if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4035                self.network
4036                    .publish_room_message(room_id.to_string(), bytes)
4037                    .await;
4038            }
4039        }
4040        // Re-announce ourselves with the new wrapped session key.
4041        if let Err(e) = self.broadcast_member_announce(room_id).await {
4042            warn!(%e, "rotate: broadcast announce failed");
4043        }
4044
4045        // Now persist the new salt on the stored row.
4046        repo::insert_room(&self.db, &info)?;
4047        Ok(())
4048    }
4049
4050    /// Used by the TUI when another member rotates a room we're in.
4051    /// Derives the new key, updates our local state, and re-announces
4052    /// so the rotator can share their fresh outbound session with us.
4053    pub async fn accept_rotation(
4054        &self,
4055        room_id: &str,
4056        new_salt: &[u8],
4057        new_passphrase: &str,
4058    ) -> Result<()> {
4059        let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4060        let info = {
4061            let mut rooms = self.active_rooms.lock().unwrap();
4062            let room = rooms
4063                .get_mut(room_id)
4064                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4065            room.passphrase_key = Some(new_key);
4066            room.info.passphrase_salt = Some(new_salt.to_vec());
4067            room.info.clone()
4068        };
4069        // Ask the rotator (and anyone) to re-share their session key
4070        // before persisting, so a crash before the DB write still leaves
4071        // peers aware we've moved to the new salt.
4072        let req = RoomMessage::SessionKeyRequest {
4073            requester_fingerprint: self.identity.fingerprint().to_string(),
4074        };
4075        if let Ok(bytes) = encode_wire(&req) {
4076            self.network
4077                .publish_room_message(room_id.to_string(), bytes)
4078                .await;
4079        }
4080        repo::insert_room(&self.db, &info)?;
4081        Ok(())
4082    }
4083
4084    // -------------------------------------------------------------------
4085    // File transfer — internal handlers
4086    // -------------------------------------------------------------------
4087
4088    #[allow(clippy::too_many_arguments)]
4089    fn handle_file_offer(
4090        &self,
4091        room_id: &str,
4092        sender_fingerprint: String,
4093        file_id: String,
4094        name: String,
4095        size_bytes: u64,
4096        mime: Option<String>,
4097        _chunk_count: u32,
4098        encrypted_meta: Option<EncryptedFileMeta>,
4099    ) {
4100        let encrypted = encrypted_meta.is_some();
4101        let attachment = StoredAttachment {
4102            id: 0,
4103            room_id: room_id.to_string(),
4104            message_id: None,
4105            sender_fingerprint: sender_fingerprint.clone(),
4106            file_id: file_id.clone(),
4107            name: name.clone(),
4108            mime,
4109            size_bytes: size_bytes as i64,
4110            status: AttachmentStatus::Offered,
4111            cache_path: None,
4112            saved_path: None,
4113            error: None,
4114            encrypted,
4115            wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4116            nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4117            megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4118            content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4119            created_at: now_unix(),
4120        };
4121        if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4122            warn!(%e, "upsert attachment");
4123            return;
4124        }
4125        // If chunks started arriving before this offer, the transfer's
4126        // size denominator was a guess — correct it with the real size.
4127        self.file_manager.set_expected_size(&file_id, size_bytes);
4128        let _ = self.app_event_tx.send(AppEvent::FileOffered {
4129            room_id: room_id.to_string(),
4130            file_id,
4131            name,
4132            size_bytes,
4133            sender_fingerprint,
4134        });
4135    }
4136
4137    fn handle_file_chunk(
4138        &self,
4139        room_id: &str,
4140        _sender_fingerprint: String,
4141        file_id: String,
4142        chunk_index: u32,
4143        total_chunks: u32,
4144        data_b64: String,
4145    ) {
4146        let data = match B64.decode(&data_b64) {
4147            Ok(d) => d,
4148            Err(e) => {
4149                warn!(%e, "bad chunk base64");
4150                return;
4151            }
4152        };
4153        // Pull the announced size + lifecycle state from our stored offer.
4154        // A terminal-state row means the user cancelled or the transfer
4155        // already failed — late chunks must not resurrect it.
4156        let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4157            Ok(Some(a)) => {
4158                if matches!(
4159                    a.status,
4160                    AttachmentStatus::Cancelled | AttachmentStatus::Failed
4161                ) {
4162                    return;
4163                }
4164                a.size_bytes as u64
4165            }
4166            Ok(None) => crate::files::MAX_FILE_SIZE,
4167            Err(e) => {
4168                warn!(%e, "get attachment for chunk");
4169                crate::files::MAX_FILE_SIZE
4170            }
4171        };
4172
4173        let result = self.file_manager.accept_chunk(
4174            &file_id,
4175            chunk_index,
4176            total_chunks,
4177            data,
4178            expected_size,
4179        );
4180        match result {
4181            Ok(None) => {
4182                // Move offered → downloading on first chunk.
4183                let _ = repo::update_attachment_status(
4184                    &self.db,
4185                    room_id,
4186                    &file_id,
4187                    AttachmentStatus::Downloading,
4188                    None,
4189                );
4190                // Best-effort progress event — we know we've processed
4191                // (chunk_index+1)/total_chunks chunks.
4192                let bytes_so_far = self
4193                    .file_manager
4194                    .progress(&file_id)
4195                    .map(|(b, _)| b)
4196                    .unwrap_or(0);
4197                let _ = self.app_event_tx.send(AppEvent::FileProgress {
4198                    file_id: file_id.clone(),
4199                    bytes_received: bytes_so_far,
4200                    total_bytes: expected_size,
4201                });
4202            }
4203            Ok(Some(completed)) => {
4204                let _ = repo::update_attachment_paths(
4205                    &self.db,
4206                    room_id,
4207                    &file_id,
4208                    Some(&completed.cache_path.to_string_lossy()),
4209                    None,
4210                );
4211                let _ = repo::update_attachment_status(
4212                    &self.db,
4213                    room_id,
4214                    &file_id,
4215                    AttachmentStatus::Ready,
4216                    None,
4217                );
4218                let _ = self.app_event_tx.send(AppEvent::FileReady {
4219                    file_id: file_id.clone(),
4220                });
4221            }
4222            Err(e) => {
4223                let msg = e.to_string();
4224                warn!(%msg, "chunk processing failed");
4225                let _ = repo::update_attachment_status(
4226                    &self.db,
4227                    room_id,
4228                    &file_id,
4229                    AttachmentStatus::Failed,
4230                    Some(&msg),
4231                );
4232                let _ = self.app_event_tx.send(AppEvent::FileFailed {
4233                    file_id: file_id.clone(),
4234                    reason: msg,
4235                });
4236            }
4237        }
4238    }
4239
4240    /// Emit MentionReceived if `body` contains either our full
4241    /// fingerprint or its short form (first hex group).
4242    fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4243        let full = self.identity.fingerprint().to_lowercase();
4244        // First hex group, e.g. "a3b1" of "a3b1-c2d4-...".
4245        let short: &str = full.split('-').next().unwrap_or(&full);
4246        let lower = body.to_lowercase();
4247        // The full fingerprint anywhere counts; the short form counts only
4248        // as a standalone hex token, so it can't match an arbitrary
4249        // substring of an unrelated hash, URL, or word.
4250        let hit = lower.contains(full.as_str())
4251            || lower
4252                .split(|c: char| !c.is_ascii_hexdigit())
4253                .any(|tok| tok == short);
4254        if hit {
4255            let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4256                room_id: room_id.to_string(),
4257                body: body.to_string(),
4258            });
4259        }
4260    }
4261
4262    fn decrypt_attachment(
4263        &self,
4264        room_id: &str,
4265        sender_fingerprint: &str,
4266        ciphertext: &[u8],
4267        meta: &EncryptedFileMeta,
4268    ) -> Result<Vec<u8>> {
4269        let mut rooms = self.active_rooms.lock().unwrap();
4270        let room = rooms
4271            .get_mut(room_id)
4272            .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4273        let crypto = room
4274            .crypto
4275            .as_mut()
4276            .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4277        file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4278    }
4279
4280    /// huddle 0.5: irreversibly delete this account. Verifies the
4281    /// master passphrase, best-effort `MemberLeave`s every joined room
4282    /// (capped at 2 s so a single unresponsive transport can't hang
4283    /// the wipe), shuts down the network, then deletes the database,
4284    /// keychain salt, log, and config files from `config::data_dir()`.
4285    /// Emits `AppEvent::WentDark` on success so the TUI can show a
4286    /// goodbye modal and exit.
4287    ///
4288    /// In `--no-master-passphrase` mode (`self.session_persist_key`
4289    /// is all-zero), the passphrase check is skipped — the typed
4290    /// `DELETE EVERYTHING` confirmation in the TUI is the only gate.
4291    pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4292        let no_master = self.session_persist_key == [0u8; 32];
4293        if !no_master {
4294            let salt = storage::keychain::load_or_create_salt()?;
4295            let candidate_master =
4296                storage::keychain::derive_master_key(master_passphrase, &salt)?;
4297            let candidate_subkey =
4298                storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4299            if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4300                return Err(HuddleError::Other(
4301                    "incorrect master passphrase".into(),
4302                ));
4303            }
4304        }
4305
4306        let room_ids: Vec<String> = self
4307            .active_rooms
4308            .lock()
4309            .unwrap()
4310            .keys()
4311            .cloned()
4312            .collect();
4313        let _ = tokio::time::timeout(Duration::from_secs(2), async {
4314            for room_id in &room_ids {
4315                if let Err(e) = self.leave_room(room_id).await {
4316                    warn!(%room_id, %e, "go_dark: leave_room failed");
4317                }
4318            }
4319        })
4320        .await;
4321
4322        self.network.shutdown().await;
4323        tokio::time::sleep(Duration::from_millis(300)).await;
4324
4325        let data_dir = config::data_dir();
4326        let candidates = [
4327            "huddle.db",
4328            "huddle.db-shm",
4329            "huddle.db-wal",
4330            "keychain.salt",
4331            "huddle.log",
4332            "config.toml",
4333        ];
4334        for name in &candidates {
4335            let path = data_dir.join(name);
4336            wipe_file(&path);
4337        }
4338        if let Ok(read) = std::fs::read_dir(&data_dir) {
4339            for entry in read.flatten() {
4340                if let Some(name) = entry.file_name().to_str() {
4341                    if name.starts_with("huddle.log.") {
4342                        wipe_file(&entry.path());
4343                    }
4344                }
4345            }
4346        }
4347        // huddle 0.5.1: wipe the attachment cache directory. Each file
4348        // inside is best-effort zeroed first, then the directory
4349        // itself is removed.
4350        let files_dir = data_dir.join("files");
4351        if let Ok(read) = std::fs::read_dir(&files_dir) {
4352            for entry in read.flatten() {
4353                let path = entry.path();
4354                if path.is_file() {
4355                    wipe_file(&path);
4356                } else if path.is_dir() {
4357                    // Two-level nesting (room_id subdirs) — sweep their
4358                    // contents too.
4359                    if let Ok(inner) = std::fs::read_dir(&path) {
4360                        for inner_entry in inner.flatten() {
4361                            if inner_entry.path().is_file() {
4362                                wipe_file(&inner_entry.path());
4363                            }
4364                        }
4365                    }
4366                    let _ = std::fs::remove_dir(&path);
4367                }
4368            }
4369        }
4370        let _ = std::fs::remove_dir(&files_dir);
4371        let _ = std::fs::remove_dir(&data_dir);
4372
4373        let _ = self.app_event_tx.send(AppEvent::WentDark);
4374        Ok(())
4375    }
4376}
4377
4378/// huddle 0.5.1: parse `input` as a huddle ID — either `HD-`-prefixed
4379/// or a bare 24-char hex run with or without dashes — and return it in
4380/// the canonical lowercase-dashed form `xxxx-xxxx-...-xxxx` that
4381/// matches `identity::compute_fingerprint`'s output. Returns None for
4382/// anything that isn't a syntactic ID (the caller falls back to
4383/// username lookup).
4384pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4385    let s = input
4386        .trim()
4387        .trim_start_matches("HD-")
4388        .trim_start_matches("hd-")
4389        .to_string();
4390    let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4391    if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4392        return None;
4393    }
4394    let lower = hex_only.to_ascii_lowercase();
4395    let chunks: Vec<String> = lower
4396        .as_bytes()
4397        .chunks(4)
4398        .map(|c| std::str::from_utf8(c).unwrap().to_string())
4399        .collect();
4400    Some(chunks.join("-"))
4401}
4402
4403/// huddle 0.5.2: rank a multiaddr by transport preference. Lower =
4404/// better. Used to sort candidate addresses for the parallel dialer so
4405/// LAN connections get a head-start over relay-hopped ones when wall-
4406/// times are close. The numeric values are arbitrary; only the
4407/// ordering matters.
4408fn address_preference(addr: &str) -> u8 {
4409    if addr.contains("/p2p-circuit") {
4410        return 9; // relay-hopped — bottom of the list
4411    }
4412    if let Some(rest) = addr.strip_prefix("/ip4/") {
4413        if let Some(ip_str) = rest.split('/').next() {
4414            if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4415                if ip.is_loopback() {
4416                    return 1; // useful for tests
4417                }
4418                if is_rfc1918(&ip) || ip.is_link_local() {
4419                    return 0; // LAN — wins ties
4420                }
4421                return 3; // public ipv4
4422            }
4423        }
4424        return 3;
4425    }
4426    if addr.starts_with("/ip6/") {
4427        return 4;
4428    }
4429    if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4430        return 5;
4431    }
4432    7
4433}
4434
4435/// True for IPv4 addresses in private (RFC 1918) ranges — 10/8,
4436/// 172.16/12, 192.168/16. Used by `address_preference` to score LAN
4437/// dials ahead of public-IP and relay-hopped ones.
4438fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4439    let octets = ip.octets();
4440    octets[0] == 10
4441        || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4442        || (octets[0] == 192 && octets[1] == 168)
4443}
4444
4445/// Short label for an HD ID, used only in error messages — strips the
4446/// fingerprint down to its first four hex chars with the brand prefix
4447/// so the message reads naturally.
4448fn short_fp_for_msg(fingerprint: &str) -> String {
4449    let head: String = fingerprint
4450        .chars()
4451        .filter(|c| *c != '-')
4452        .take(4)
4453        .collect::<String>()
4454        .to_ascii_uppercase();
4455    format!("HD-{}…", head)
4456}
4457
4458/// Constant-time 32-byte equality. Used by `go_dark` to compare a
4459/// re-derived HKDF subkey to the in-memory `session_persist_key`
4460/// without leaking timing information about which byte differed.
4461fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4462    let mut diff = 0u8;
4463    for i in 0..32 {
4464        diff |= a[i] ^ b[i];
4465    }
4466    diff == 0
4467}
4468
4469/// Best-effort file wipe: overwrite with zeros, then delete. Missing /
4470/// permission-denied files are logged and skipped. Called from
4471/// `go_dark` only — not a general-purpose util.
4472fn wipe_file(path: &Path) {
4473    use std::io::Write;
4474    if let Ok(meta) = std::fs::metadata(path) {
4475        if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4476            let zeros = vec![0u8; meta.len() as usize];
4477            let _ = f.write_all(&zeros);
4478            let _ = f.sync_all();
4479        }
4480    }
4481    if let Err(e) = std::fs::remove_file(path) {
4482        if e.kind() != std::io::ErrorKind::NotFound {
4483            warn!(?path, %e, "wipe_file: remove failed");
4484        }
4485    }
4486}
4487
4488/// Use the platform's default opener on `path`.
4489fn open_with_system(path: &str) -> Result<()> {
4490    #[cfg(target_os = "macos")]
4491    let cmd = "open";
4492    #[cfg(target_os = "linux")]
4493    let cmd = "xdg-open";
4494    #[cfg(target_os = "windows")]
4495    let cmd = "cmd";
4496    #[cfg(target_os = "windows")]
4497    let args = vec!["/C", "start", "", path];
4498    #[cfg(not(target_os = "windows"))]
4499    let args = vec![path];
4500
4501    std::process::Command::new(cmd)
4502        .args(args)
4503        .spawn()
4504        .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4505    Ok(())
4506}
4507
4508// Module-level salt cache: room_id -> salt. Populated when we receive
4509// announcements; queried by join_room.
4510static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4511    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4512
4513/// Public accessor for the Argon2id salt length used when deriving room
4514/// passphrase keys. Exists so downstream tooling (status pages, debug
4515/// CLIs, integration tests) can confirm the expected size without
4516/// re-importing the constant from `crypto::passphrase`.
4517pub fn salt_len() -> usize {
4518    SALT_LEN
4519}
4520
4521fn now_unix() -> i64 {
4522    SystemTime::now()
4523        .duration_since(UNIX_EPOCH)
4524        .unwrap()
4525        .as_secs() as i64
4526}
4527
4528fn now_unix_ms() -> i64 {
4529    SystemTime::now()
4530        .duration_since(UNIX_EPOCH)
4531        .unwrap()
4532        .as_millis() as i64
4533}
4534
4535/// Phase B: generate a fresh 24-char base64-ish passphrase for the
4536/// rotation that follows a kick. Sourced from `OsRng` directly so the
4537/// kicker doesn't have to think up a strong one on the spot. Returned
4538/// to the owner via the kick-result modal for OOB sharing with the
4539/// remaining members.
4540fn generate_join_passphrase() -> String {
4541    use rand::RngCore;
4542    let mut bytes = [0u8; 16];
4543    rand::thread_rng().fill_bytes(&mut bytes);
4544    // Use URL-safe-no-pad so the user can read aloud / paste without
4545    // worrying about `=` padding or `+` getting URL-escaped.
4546    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4547}
4548
4549/// Phase F: short human-readable join code. 8 chars from a 32-symbol
4550/// alphabet (no easily-confused chars like 0/O/I/1) ≈ 40 bits — plenty
4551/// for a 10-minute online gate since the owner's client checks
4552/// exact-match (not brute-force-able offline).
4553fn generate_alphanumeric_code(len: usize) -> String {
4554    use rand::Rng;
4555    const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4556    let mut rng = rand::thread_rng();
4557    let mut out = String::with_capacity(len + 1);
4558    for i in 0..len {
4559        if i == 4 && len == 8 {
4560            out.push('-'); // pretty: XXXX-XXXX
4561        }
4562        let idx = rng.gen_range(0..ALPHABET.len());
4563        out.push(ALPHABET[idx] as char);
4564    }
4565    out
4566}
4567
4568#[cfg(test)]
4569mod parser_tests {
4570    use super::parse_dial_address;
4571
4572    #[test]
4573    fn parses_ipv4_port() {
4574        let m = parse_dial_address("10.3.72.53:9027").unwrap();
4575        assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4576    }
4577
4578    #[test]
4579    fn parses_bracketed_ipv6() {
4580        let m = parse_dial_address("[::1]:9027").unwrap();
4581        assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4582    }
4583
4584    #[test]
4585    fn rejects_unbracketed_ipv6() {
4586        let err = parse_dial_address("fe80::1:9027").unwrap_err();
4587        assert!(err.to_string().contains("brackets"));
4588    }
4589
4590    #[test]
4591    fn passes_through_raw_multiaddr() {
4592        let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4593        assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4594    }
4595
4596    #[test]
4597    fn empty_address_is_error() {
4598        assert!(parse_dial_address("   ").is_err());
4599    }
4600
4601    #[test]
4602    fn rejects_bad_port() {
4603        assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4604    }
4605}
4606
4607#[cfg(test)]
4608mod transport_preference_tests {
4609    use super::{address_preference, normalize_to_fingerprint};
4610
4611    #[test]
4612    fn lan_beats_public_beats_circuit() {
4613        let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4614        let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4615        let circuit = address_preference(
4616            "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4617        );
4618        assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4619        assert!(
4620            pub_v4 < circuit,
4621            "public {} should beat circuit {}",
4622            pub_v4,
4623            circuit
4624        );
4625    }
4626
4627    #[test]
4628    fn all_rfc1918_ranges_are_lan() {
4629        assert_eq!(
4630            address_preference("/ip4/10.0.0.1/tcp/9027"),
4631            address_preference("/ip4/192.168.0.1/tcp/9027"),
4632        );
4633        assert_eq!(
4634            address_preference("/ip4/172.16.0.1/tcp/9027"),
4635            address_preference("/ip4/192.168.0.1/tcp/9027"),
4636        );
4637        // 172.32.x.x is OUTSIDE the 172.16-31 RFC1918 slice.
4638        assert!(
4639            address_preference("/ip4/172.32.0.1/tcp/9027")
4640                > address_preference("/ip4/172.16.0.1/tcp/9027")
4641        );
4642    }
4643
4644    #[test]
4645    fn normalize_id_accepts_branded_and_raw() {
4646        let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4647        assert_eq!(
4648            normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4649            Some(canon)
4650        );
4651        assert_eq!(
4652            normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4653            Some(canon)
4654        );
4655        assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4656        assert!(normalize_to_fingerprint("alice").is_none());
4657        assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4658    }
4659}
4660
4661#[cfg(test)]
4662mod canonical_dm_room_id_tests {
4663    use super::canonical_dm_room_id;
4664
4665    #[test]
4666    fn dm_room_id_is_commutative() {
4667        // The single load-bearing property: both peers, no matter who
4668        // calls `start_direct` first, derive identical IDs.
4669        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4670        let b = "1111-2222-3333-4444-5555-6666";
4671        assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4672    }
4673
4674    #[test]
4675    fn dm_room_id_differs_per_pair() {
4676        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4677        let b = "1111-2222-3333-4444-5555-6666";
4678        let c = "9999-8888-7777-6666-5555-4444";
4679        assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4680        assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4681    }
4682
4683    #[test]
4684    fn dm_room_id_is_stable() {
4685        // Deterministic by construction; this guards against
4686        // accidentally mixing in a timestamp or nonce in a future
4687        // refactor — that would break idempotency across peers.
4688        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4689        let b = "1111-2222-3333-4444-5555-6666";
4690        let id1 = canonical_dm_room_id(a, b);
4691        let id2 = canonical_dm_room_id(a, b);
4692        assert_eq!(id1, id2);
4693        // Same length as `derive_room_id` output (32 hex chars / 16
4694        // bytes) so DM IDs are indistinguishable from group IDs at the
4695        // topic-name layer.
4696        assert_eq!(id1.len(), 32);
4697    }
4698}