Skip to main content

huddle_core/app/
mod.rs

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