Skip to main content

huddle_core/app/
mod.rs

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