Skip to main content

huddle_core/app/
mod.rs

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