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