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    pub fn known_peers(&self) -> Vec<KnownPeerStatus> {
1440        let connected = self.connected_dial_addrs.lock().unwrap().clone();
1441        let stored = repo::list_known_peers(&self.db).unwrap_or_default();
1442        stored
1443            .into_iter()
1444            .map(|p| {
1445                let connected_peer = connected.get(&p.address).copied();
1446                KnownPeerStatus {
1447                    address: p.address,
1448                    label: p.label,
1449                    last_connected_at: p.last_connected_at,
1450                    connected_peer_id: connected_peer,
1451                    fingerprint: p.fingerprint,
1452                }
1453            })
1454            .collect()
1455    }
1456
1457    pub async fn forget_peer(&self, address: &str) -> Result<()> {
1458        repo::forget_known_peer(&self.db, address)?;
1459        self.connected_dial_addrs.lock().unwrap().remove(address);
1460        Ok(())
1461    }
1462
1463    /// Re-dial a stored address — used by the lobby's "reconnect" action.
1464    pub async fn redial(&self, address: &str) -> Result<()> {
1465        self.dial(address).await
1466    }
1467
1468    /// Phase A: user pressed Accept on the inbound-dial modal. Promotes
1469    /// the peer to the gossipsub mesh. Does NOT mark them trusted —
1470    /// that's `trust_inbound`, the explicit "remember and bypass next
1471    /// time" path.
1472    pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
1473        self.network.accept_inbound(peer_id).await;
1474        self.connected_dial_addrs
1475            .lock()
1476            .unwrap()
1477            .insert(address.to_string(), peer_id);
1478    }
1479
1480    /// Phase A: user pressed Reject on the inbound-dial modal. Disconnects
1481    /// the peer, adds them to the persistent blocklist, and ensures every
1482    /// subsequent connection attempt from this fingerprint is auto-
1483    /// dropped without re-prompting.
1484    pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
1485        self.network.reject_inbound(peer_id).await;
1486        repo::block_peer(&self.db, fingerprint, now_unix())?;
1487        Ok(())
1488    }
1489
1490    /// Phase A: user pressed Trust+Accept — accept the connection AND
1491    /// remember the peer so subsequent connections bypass the modal.
1492    pub async fn trust_inbound(
1493        &self,
1494        peer_id: PeerId,
1495        fingerprint: &str,
1496        address: &str,
1497    ) -> Result<()> {
1498        self.network.accept_inbound(peer_id).await;
1499        self.connected_dial_addrs
1500            .lock()
1501            .unwrap()
1502            .insert(address.to_string(), peer_id);
1503        // Persist the row with trusted=true so future inbound from
1504        // this fingerprint short-circuits the modal in
1505        // `process_network_event`'s InboundDial handler.
1506        repo::upsert_known_peer(
1507            &self.db,
1508            &KnownPeer {
1509                address: address.to_string(),
1510                label: None,
1511                last_connected_at: Some(now_unix()),
1512                last_attempt_at: Some(now_unix()),
1513                created_at: now_unix(),
1514                fingerprint: Some(fingerprint.to_string()),
1515                trusted: true,
1516            },
1517        )?;
1518        Ok(())
1519    }
1520
1521    // =========================================================================
1522    // huddle 0.7.7: pending friend requests (3-day TTL)
1523    // =========================================================================
1524
1525    /// Snapshot of every inbound dial we've spilled to disk but haven't
1526    /// yet accepted or rejected. The People pane renders this as its
1527    /// own section ("Pending requests (N)").
1528    pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
1529        repo::list_pending_friend_requests(&self.db).unwrap_or_default()
1530    }
1531
1532    /// Persist an inbound request that the user didn't act on within the
1533    /// modal window. Called from the TUI's idle-timeout sweep; the live
1534    /// libp2p connection is also closed by the same path (the request
1535    /// is effectively rejected *for now* — accept later from People
1536    /// pane will re-dial the stored address).
1537    pub fn spill_pending_friend_request(
1538        &self,
1539        peer_id: PeerId,
1540        fingerprint: &str,
1541        address: &str,
1542    ) -> Result<()> {
1543        repo::upsert_pending_friend_request(
1544            &self.db,
1545            &repo::PendingFriendRequest {
1546                fingerprint: fingerprint.to_string(),
1547                address: address.to_string(),
1548                peer_id: peer_id.to_string(),
1549                received_at: now_unix(),
1550            },
1551        )?;
1552        Ok(())
1553    }
1554
1555    /// User pressed Accept on a row in the Pending requests list. The
1556    /// original libp2p connection is long gone (we closed it on
1557    /// timeout); re-dial the stored address and mark the peer trusted
1558    /// so the post-Identify handler short-circuits the modal. The
1559    /// row is removed regardless of dial success — a failed dial is
1560    /// still a positive intent we don't want to keep re-prompting on.
1561    pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1562        let mut chosen_addr: Option<String> = None;
1563        for req in self.list_pending_friend_requests() {
1564            if req.fingerprint == fingerprint {
1565                chosen_addr = Some(req.address);
1566                break;
1567            }
1568        }
1569        repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1570        if let Some(addr) = chosen_addr {
1571            // Pre-mark trusted so the upcoming Identify handler skips
1572            // the inbound-dial modal. Matches the semantics of
1573            // `trust_inbound` without needing a live PeerId.
1574            repo::upsert_known_peer(
1575                &self.db,
1576                &KnownPeer {
1577                    address: addr.clone(),
1578                    label: None,
1579                    last_connected_at: None,
1580                    last_attempt_at: Some(now_unix()),
1581                    created_at: now_unix(),
1582                    fingerprint: Some(fingerprint.to_string()),
1583                    trusted: true,
1584                },
1585            )?;
1586            // User-initiated — register for auto-DM on connect.
1587            self.dial(&addr).await?;
1588        }
1589        Ok(())
1590    }
1591
1592    /// User pressed Reject on a row in the Pending requests list.
1593    /// Mirrors `reject_inbound` semantics: delete the pending row(s)
1594    /// AND block the fingerprint so any future dial from this peer is
1595    /// auto-dropped without re-prompting.
1596    pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
1597        repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
1598        repo::block_peer(&self.db, fingerprint, now_unix())?;
1599        Ok(())
1600    }
1601
1602    /// huddle 0.7.7: close a live libp2p connection without blocking the
1603    /// peer. Used by the TUI's 15s InboundDial timeout — we need to
1604    /// drop the dangling socket, but blocking the peer would
1605    /// contradict "save the request for 3 days, let the user decide
1606    /// later." `reject_inbound` is the right call when the user
1607    /// *explicitly* clicks Reject.
1608    pub async fn disconnect_peer(&self, peer_id: PeerId) {
1609        self.network.disconnect_peer(peer_id).await;
1610    }
1611
1612    fn spawn_known_peer_reconnector(&self) {
1613        let handle = self.clone();
1614        tokio::spawn(async move {
1615            // Brief delay so our own listeners come up first.
1616            tokio::time::sleep(Duration::from_millis(500)).await;
1617            let known = repo::list_known_peers(&handle.db).unwrap_or_default();
1618            // Reconnect each peer from its own task on a staggered, jittered
1619            // delay so a long known-peer list doesn't fire a synchronized
1620            // burst of dials (and serialized DB writes) all at once.
1621            for (i, peer) in known.into_iter().enumerate() {
1622                let handle = handle.clone();
1623                tokio::spawn(async move {
1624                    // Deterministic per-address jitter de-correlates peers
1625                    // without pulling an RNG into scope.
1626                    let jitter = (peer.address.len() as u64 * 37) % 200;
1627                    tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
1628                    // huddle 0.7.7: route through `dial_internal`, NOT
1629                    // `dial`. Startup reconnects shouldn't pop a DM
1630                    // every time a known peer comes online — only
1631                    // explicit user actions trigger the auto-DM.
1632                    let multiaddr = match peer.address.parse::<Multiaddr>() {
1633                        Ok(m) => m,
1634                        Err(_) => return,
1635                    };
1636                    if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
1637                        debug!(%e, addr = %peer.address, "auto-reconnect failed");
1638                    }
1639                });
1640            }
1641        });
1642    }
1643
1644    // -------------------------------------------------------------------
1645    // Internal helpers
1646    // -------------------------------------------------------------------
1647
1648    fn load_or_create_identity(db: &Db) -> Result<Identity> {
1649        if let Some(stored) = repo::load_identity(db)? {
1650            let mut bytes = [0u8; 32];
1651            bytes.copy_from_slice(&stored.ed25519_secret);
1652            Identity::from_secret_bytes(bytes)
1653        } else {
1654            let id = Identity::generate()?;
1655            repo::save_identity(db, &id.secret_bytes(), now_unix())?;
1656            Ok(id)
1657        }
1658    }
1659
1660    fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
1661        self.active_rooms
1662            .lock()
1663            .unwrap()
1664            .get(room_id)
1665            .and_then(|r| r.info.passphrase_salt.clone())
1666            .or_else(|| {
1667                // Try the cached announcement salt
1668                ROOM_SALT_CACHE
1669                    .lock()
1670                    .unwrap()
1671                    .get(room_id)
1672                    .cloned()
1673            })
1674    }
1675
1676    async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
1677        let owner_fingerprints =
1678            repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
1679        let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
1680        let host_addrs = self.dialable_addrs();
1681        let ann = RoomAnnouncement {
1682            room_id: info.id.clone(),
1683            name: info.name.clone(),
1684            encrypted: info.encrypted,
1685            passphrase_salt: info.passphrase_salt.clone(),
1686            member_count,
1687            creator_fingerprint: info.creator_fingerprint.clone(),
1688            announced_at: now_unix(),
1689            owner_fingerprints,
1690            verified_only,
1691            host_addrs,
1692            kind: info.kind,
1693        };
1694        self.network.announce_room(ann).await;
1695    }
1696
1697    async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
1698        let our_fp = self.identity.fingerprint().to_string();
1699        let wrapped = {
1700            let mut rooms = self.active_rooms.lock().unwrap();
1701            let room = rooms
1702                .get_mut(room_id)
1703                .ok_or_else(|| HuddleError::Other("not in room".into()))?;
1704            if room.info.encrypted {
1705                let crypto = room.crypto.as_mut().unwrap();
1706                let session_key = crypto.our_session_key_b64();
1707                match room.passphrase_key.as_ref() {
1708                    Some(passphrase_key) => {
1709                        Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
1710                    }
1711                    None if room.info.kind == RoomKind::Direct => {
1712                        // huddle 0.7.1: DM-specific path — partner's
1713                        // pubkey hasn't been observed yet, so we can't
1714                        // derive the ECDH key. Send announce without
1715                        // a wrapped key — it carries our Ed25519
1716                        // pubkey, which lets the partner derive the
1717                        // key on their side. They'll respond with
1718                        // their own wrapped key in a follow-up
1719                        // announce; once we receive it we re-broadcast
1720                        // ours with the wrap filled in.
1721                        None
1722                    }
1723                    None => {
1724                        return Err(HuddleError::Session("missing passphrase key".into()));
1725                    }
1726                }
1727            } else {
1728                None
1729            }
1730        };
1731        let display_name = repo::get_display_name(&self.db).unwrap_or(None);
1732        let msg = RoomMessage::MemberAnnounce {
1733            sender_fingerprint: our_fp,
1734            wrapped_session_key: wrapped,
1735            display_name,
1736            sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
1737        };
1738        // huddle 0.7.11: MemberAnnounce is now signed end-to-end. The
1739        // envelope's Ed25519 pubkey is the canonical TOFU pin for this
1740        // fingerprint; the inner `sender_ed25519_pubkey` field stays
1741        // present for back-compat parsing but is no longer authoritative.
1742        let env = crate::crypto::sign_message(&self.identity, &msg)?;
1743        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
1744        self.network
1745            .publish_room_message(room_id.to_string(), bytes)
1746            .await;
1747        Ok(())
1748    }
1749
1750    fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
1751        let handle = self.clone();
1752        tokio::spawn(async move {
1753            while let Some(event) = net_rx.recv().await {
1754                handle.process_network_event(event).await;
1755            }
1756            info!("event processor stopped");
1757        });
1758    }
1759
1760    fn spawn_announcement_ticker(&self) {
1761        let handle = self.clone();
1762        tokio::spawn(async move {
1763            let mut interval =
1764                tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
1765            interval.tick().await; // skip the immediate tick
1766            loop {
1767                interval.tick().await;
1768                let snapshot: Vec<(StoredRoom, u32)> = {
1769                    let active = handle.active_rooms.lock().unwrap();
1770                    active
1771                        .values()
1772                        .map(|r| (r.info.clone(), r.members.len() as u32))
1773                        .collect()
1774                };
1775                for (info, member_count) in snapshot {
1776                    handle.announce_room_now(&info, member_count).await;
1777                }
1778            }
1779        });
1780    }
1781
1782    fn spawn_discovered_room_pruner(&self) {
1783        let handle = self.clone();
1784        tokio::spawn(async move {
1785            let mut interval = tokio::time::interval(Duration::from_secs(10));
1786            interval.tick().await;
1787            loop {
1788                interval.tick().await;
1789                let now = now_unix();
1790                let mut to_drop = Vec::new();
1791                {
1792                    let mut map = handle.discovered_rooms.lock().unwrap();
1793                    map.retain(|id, r| {
1794                        if now - r.last_seen > DISCOVERED_TTL_SECS {
1795                            to_drop.push(id.clone());
1796                            false
1797                        } else {
1798                            true
1799                        }
1800                    });
1801                }
1802                for id in to_drop {
1803                    let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
1804                }
1805            }
1806        });
1807    }
1808
1809    async fn process_network_event(&self, event: NetworkEvent) {
1810        match event {
1811            NetworkEvent::PeerDiscovered { peer_id } => {
1812                let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
1813            }
1814            NetworkEvent::PeerExpired { peer_id } => {
1815                // Drop any tracked dial-connection entry for this peer so
1816                // the lobby's online/offline dots stay accurate. mDNS
1817                // expiry only gives us a PeerId (no fingerprint), so we
1818                // can't touch room membership here — that relies on the
1819                // explicit MemberLeave path and the discovered-room TTL.
1820                self.connected_dial_addrs
1821                    .lock()
1822                    .unwrap()
1823                    .retain(|_addr, pid| *pid != peer_id);
1824                let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1825            }
1826            NetworkEvent::PeerDisconnected { peer_id } => {
1827                // huddle 0.7.11: relay / internet peers don't trigger
1828                // mDNS PeerExpired, so without this their entries in
1829                // connected_dial_addrs stayed forever and the lobby
1830                // showed them as "● online" indefinitely after they
1831                // dropped. Same cleanup shape as PeerExpired.
1832                self.connected_dial_addrs
1833                    .lock()
1834                    .unwrap()
1835                    .retain(|_addr, pid| *pid != peer_id);
1836                let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
1837            }
1838            // huddle 0.7.12: `RelayReservationLost` was removed —
1839            // libp2p 0.56's relay client doesn't surface a failure
1840            // variant we can listen on. Reservation loss currently
1841            // manifests as the next AutoNAT probe flipping to
1842            // "private" once the circuit drops; a future health-
1843            // check timer can re-introduce the dedicated signal.
1844            NetworkEvent::ListeningOn { address } => {
1845                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
1846                    address: address.to_string(),
1847                });
1848            }
1849            NetworkEvent::RoomAnnouncementReceived(ann) => {
1850                // Cache the salt for join_room
1851                if let Some(salt) = &ann.passphrase_salt {
1852                    ROOM_SALT_CACHE
1853                        .lock()
1854                        .unwrap()
1855                        .insert(ann.room_id.clone(), salt.clone());
1856                }
1857                // Phase D follow-up: opportunistically dial the
1858                // announcer's first host_addr if we're not already
1859                // connected. Skips self-announcements + rate-limits
1860                // by creator fingerprint so we don't dial-storm.
1861                let our_fp_for_dial = self.identity.fingerprint().to_string();
1862                if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
1863                    let now = now_unix();
1864                    let should_dial = {
1865                        let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
1866                        match attempts.get(&ann.creator_fingerprint).copied() {
1867                            Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
1868                            _ => {
1869                                attempts.insert(ann.creator_fingerprint.clone(), now);
1870                                true
1871                            }
1872                        }
1873                    };
1874                    if should_dial {
1875                        if let Some(first) = ann.host_addrs.first() {
1876                            info!(
1877                                announcer = %ann.creator_fingerprint,
1878                                addr = %first,
1879                                "opportunistic dial via room announcement host_addrs"
1880                            );
1881                            // huddle 0.7.7: NOT user-initiated — go
1882                            // through `dial_internal` so a passive
1883                            // announcement-driven dial doesn't pop a
1884                            // DM in the user's face.
1885                            if let Ok(multiaddr) = first.parse::<Multiaddr>() {
1886                                let canonical = multiaddr.to_string();
1887                                let _ = self.dial_internal(canonical, multiaddr).await;
1888                            }
1889                        }
1890                    }
1891                }
1892                let discovered = DiscoveredRoom {
1893                    room_id: ann.room_id.clone(),
1894                    name: ann.name.clone(),
1895                    encrypted: ann.encrypted,
1896                    member_count: ann.member_count,
1897                    creator_fingerprint: ann.creator_fingerprint.clone(),
1898                    last_seen: now_unix(),
1899                    restorable: false,
1900                    host_addrs: ann.host_addrs.clone(),
1901                    kind: ann.kind,
1902                };
1903                // If we're already in this room, cache the announcement so
1904                // others can still discover it through us, but don't emit
1905                // RoomDiscovered — it isn't "newly discovered" to us, and
1906                // emitting it spuriously re-opens the lobby join prompt.
1907                if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
1908                    self.discovered_rooms
1909                        .lock()
1910                        .unwrap()
1911                        .insert(ann.room_id.clone(), discovered);
1912                    return;
1913                }
1914                // huddle 0.7 DM-visibility filter (consumer side): a
1915                // `Direct` announcement is only valid for the two members
1916                // implied by `canonical_dm_room_id`. If we're not one of
1917                // them, silently drop — DMs never appear in third
1918                // parties' discovery caches. A malicious 0.7+ peer can
1919                // ignore this, but they'd have to subscribe to the
1920                // canonical DM topic with full knowledge of both
1921                // fingerprints, which is a stronger threat than the v1
1922                // sidebar split is trying to mitigate.
1923                if ann.kind == RoomKind::Direct {
1924                    let our_fp_for_filter = self.identity.fingerprint().to_string();
1925                    if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
1926                        != ann.room_id
1927                    {
1928                        debug!(
1929                            announcer = %ann.creator_fingerprint,
1930                            room_id = %ann.room_id,
1931                            "dropping Direct announcement: not addressed to us"
1932                        );
1933                        return;
1934                    }
1935                    // Targeted at us. Cache the discovery so the sidebar
1936                    // can show "DM from <partner>" and auto-bootstrap a
1937                    // local active room so we can receive messages
1938                    // immediately without waiting for a user action.
1939                    //
1940                    // huddle 0.7.11: drop the auto-bootstrap if the
1941                    // partner is on the persistent blocklist. Without
1942                    // this gate, a blocked peer could re-introduce
1943                    // themselves into our sidebar simply by re-announcing
1944                    // the DM topic; we'd subscribe and persist a row for
1945                    // them before any user action.
1946                    if repo::is_peer_blocked(&self.db, &ann.creator_fingerprint).unwrap_or(false)
1947                    {
1948                        debug!(
1949                            partner = %ann.creator_fingerprint,
1950                            "ignoring Direct announcement from blocked peer"
1951                        );
1952                        return;
1953                    }
1954                    self.discovered_rooms
1955                        .lock()
1956                        .unwrap()
1957                        .insert(ann.room_id.clone(), discovered.clone());
1958                    let _ = self
1959                        .app_event_tx
1960                        .send(AppEvent::RoomDiscovered(discovered.clone()));
1961                    let app = self.clone();
1962                    let partner = ann.creator_fingerprint.clone();
1963                    let rid = ann.room_id.clone();
1964                    tokio::spawn(async move {
1965                        if let Err(e) = app.start_direct(&partner).await {
1966                            debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
1967                        }
1968                    });
1969                    return;
1970                }
1971                self.discovered_rooms
1972                    .lock()
1973                    .unwrap()
1974                    .insert(ann.room_id.clone(), discovered.clone());
1975                let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
1976            }
1977            NetworkEvent::RoomMessageReceived {
1978                room_id,
1979                payload,
1980                from_peer: _,
1981            } => {
1982                // v0.3.0+: every wire message is a `WireMessage` envelope.
1983                // `Plain` carries an unsigned `RoomMessage`; `Signed` is an
1984                // app-level Ed25519 envelope that we verify before
1985                // unwrapping. A failed verify is logged and dropped — we
1986                // never dispatch unverified-but-claiming-to-be-signed
1987                // messages.
1988                let wire: WireMessage = match serde_json::from_slice(&payload) {
1989                    Ok(w) => w,
1990                    Err(e) => {
1991                        warn!(%e, "bad wire envelope");
1992                        return;
1993                    }
1994                };
1995                let (msg, verified_signer) = match wire {
1996                    WireMessage::Plain(m) => (m, None),
1997                    WireMessage::Signed(env) => {
1998                        let claimed_pubkey = env.ed25519_pubkey_b64.clone();
1999                        match crate::crypto::verify_signed(&env) {
2000                            Ok((m, fp)) => {
2001                                // Defense in depth: if we've persisted
2002                                // a pubkey for this fingerprint in this
2003                                // room before, the envelope's pubkey
2004                                // MUST match it. A different pubkey for
2005                                // the same fingerprint means identity
2006                                // drift — TOFU violation — drop.
2007                                match repo::get_member_ed25519_pubkey(
2008                                    &self.db, &room_id, &fp,
2009                                ) {
2010                                    Ok(Some(known)) if known != claimed_pubkey => {
2011                                        warn!(
2012                                            %fp, %room_id,
2013                                            "pubkey mismatch vs stored; dropping signed message"
2014                                        );
2015                                        return;
2016                                    }
2017                                    _ => {}
2018                                }
2019                                (m, Some(fp))
2020                            }
2021                            Err(e) => {
2022                                warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
2023                                return;
2024                            }
2025                        }
2026                    }
2027                };
2028                self.handle_room_message(&room_id, msg, verified_signer).await;
2029            }
2030            NetworkEvent::DialSucceeded { peer_id, address } => {
2031                let addr_s = address.to_string();
2032                self.connected_dial_addrs
2033                    .lock()
2034                    .unwrap()
2035                    .insert(addr_s.clone(), peer_id);
2036                // Fingerprint isn't known yet (Identify hasn't landed);
2037                // the PeerIdentified handler below upserts again to add
2038                // the fingerprint and flip trusted=true once it does.
2039                let _ = repo::upsert_known_peer(
2040                    &self.db,
2041                    &KnownPeer {
2042                        address: addr_s.clone(),
2043                        label: None,
2044                        last_connected_at: Some(now_unix()),
2045                        last_attempt_at: Some(now_unix()),
2046                        created_at: now_unix(),
2047                        fingerprint: None,
2048                        trusted: false,
2049                    },
2050                );
2051                let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
2052                    address: addr_s,
2053                    peer_id,
2054                });
2055            }
2056            NetworkEvent::DialFailed { address, error } => {
2057                let addr_s = address.to_string();
2058                let _ = self.app_event_tx.send(AppEvent::DialFailed {
2059                    address: addr_s,
2060                    error,
2061                });
2062            }
2063            NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2064                // For any address we user-dialed for this peer, retroactively
2065                // backfill the fingerprint and flip trusted=true. The
2066                // upsert's COALESCE preserves fingerprint once set and
2067                // its trusted-is-sticky-once-true clause means we don't
2068                // accidentally demote a row that was already trusted.
2069                let matched_addrs: Vec<String> = {
2070                    let map = self.connected_dial_addrs.lock().unwrap();
2071                    map.iter()
2072                        .filter_map(|(addr, pid)| {
2073                            if *pid == peer_id {
2074                                Some(addr.clone())
2075                            } else {
2076                                None
2077                            }
2078                        })
2079                        .collect()
2080                };
2081                // Phase C follow-up: if any of these addresses came
2082                // from an invite, verify the invite's claimed fp
2083                // against what we just derived from the pubkey. A
2084                // mismatch means the invite's fp label disagrees with
2085                // libp2p's /p2p/<peer-id> cryptographic anchor —
2086                // structurally impossible when both fields are
2087                // generated from the same identity, but the explicit
2088                // assert defends against future invite-format
2089                // changes or hand-edited links.
2090                let mismatch = {
2091                    let mut map = self.pending_invite_dials.lock().unwrap();
2092                    let mut found: Option<(String, String)> = None;
2093                    for addr in &matched_addrs {
2094                        if let Some(claimed) = map.remove(addr) {
2095                            if claimed != fingerprint {
2096                                found = Some((addr.clone(), claimed));
2097                                break;
2098                            }
2099                        }
2100                    }
2101                    found
2102                };
2103                if let Some((addr, claimed)) = mismatch {
2104                    warn!(
2105                        %addr, %claimed, actual=%fingerprint,
2106                        "invite fingerprint mismatch — disconnecting"
2107                    );
2108                    self.network.disconnect_peer(peer_id).await;
2109                    let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2110                        address: addr,
2111                        claimed,
2112                        actual: fingerprint.clone(),
2113                    });
2114                    return;
2115                }
2116                // huddle 0.7.7: did the local user initiate any of these
2117                // dials? If so, consume the matching entries from
2118                // `pending_auto_dm_addrs` now so we don't auto-DM
2119                // again on a subsequent reconnect. The actual DM
2120                // start happens after the trust upsert below so the
2121                // peer is already marked trusted by the time we fire.
2122                let should_auto_dm = {
2123                    let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
2124                    let mut any_matched = false;
2125                    for addr in &matched_addrs {
2126                        if pending.remove(addr) {
2127                            any_matched = true;
2128                        }
2129                    }
2130                    any_matched
2131                };
2132                for addr in matched_addrs {
2133                    let _ = repo::upsert_known_peer(
2134                        &self.db,
2135                        &KnownPeer {
2136                            address: addr,
2137                            label: None,
2138                            last_connected_at: Some(now_unix()),
2139                            last_attempt_at: Some(now_unix()),
2140                            created_at: now_unix(),
2141                            fingerprint: Some(fingerprint.clone()),
2142                            trusted: true,
2143                        },
2144                    );
2145                }
2146                // huddle 0.7.7: open (or reuse) a DM with the freshly
2147                // identified peer and tell the TUI to switch panes.
2148                // `start_direct` is idempotent on `canonical_dm_room_id`,
2149                // so this is safe to call even if a DM already exists.
2150                //
2151                // huddle 0.7.11: explicitly gate on the persistent
2152                // blocklist here. The original comment claimed blocked
2153                // peers "fall through naturally" but that was only true
2154                // for *inbound* dials — the block check at line ~2237
2155                // is inbound-only. Outbound user-dials hit Identify and
2156                // landed here without ever consulting the blocklist,
2157                // bypassing the user's explicit block.
2158                let blocked = repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false);
2159                if should_auto_dm && !blocked && fingerprint != self.identity.fingerprint() {
2160                    match self.start_direct(&fingerprint).await {
2161                        Ok(room_id) => {
2162                            let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
2163                                room_id,
2164                                fingerprint: fingerprint.clone(),
2165                            });
2166                        }
2167                        Err(e) => {
2168                            debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
2169                        }
2170                    }
2171                }
2172                // huddle 0.5: tell the newly-identified peer our current
2173                // username via a signed ProfileUpdate, but only if we
2174                // have one set locally and we haven't already pushed
2175                // ours to this peer in the last
2176                // `PROFILE_REBROADCAST_FLOOR_MS`. Without the floor a
2177                // flapping transport (relay reconnect storms) would
2178                // republish on every identify event.
2179                let our_username = repo::get_display_name(&self.db).unwrap_or(None);
2180                if our_username.is_some() {
2181                    let now_ms = now_unix_ms();
2182                    let should_send = {
2183                        let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
2184                        match last.get(&fingerprint) {
2185                            Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
2186                            _ => {
2187                                last.insert(fingerprint.clone(), now_ms);
2188                                true
2189                            }
2190                        }
2191                    };
2192                    if should_send {
2193                        let msg = RoomMessage::ProfileUpdate {
2194                            sender_fingerprint: self.identity.fingerprint().to_string(),
2195                            username: our_username,
2196                            updated_at: now_ms,
2197                        };
2198                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2199                            if let Ok(bytes) =
2200                                crate::network::protocol::encode_wire_signed(&env)
2201                            {
2202                                let rooms: Vec<String> = self
2203                                    .active_rooms
2204                                    .lock()
2205                                    .unwrap()
2206                                    .keys()
2207                                    .cloned()
2208                                    .collect();
2209                                for room_id in rooms {
2210                                    self.network
2211                                        .publish_room_message(room_id, bytes.clone())
2212                                        .await;
2213                                }
2214                            }
2215                        }
2216                    }
2217                }
2218            }
2219            NetworkEvent::RelayReservationEstablished { address } => {
2220                // Treat the circuit address like any other listen
2221                // address — the TUI's ListeningOn handler dedups + adds
2222                // it to the addresses pane. Also emit a status hint via
2223                // ListeningOn so the lobby's reachability line updates.
2224                info!(addr = %address, "relay reservation established");
2225                self.relay_circuit_addrs
2226                    .lock()
2227                    .unwrap()
2228                    .insert(address.to_string());
2229                let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2230                    address: address.to_string(),
2231                });
2232            }
2233            NetworkEvent::NatProbeResult {
2234                tested_addr,
2235                reachable,
2236            } => {
2237                let addr_s = tested_addr.to_string();
2238                let (transitioned, becomes_reachable) = {
2239                    let mut set = self.nat_reachable_addrs.lock().unwrap();
2240                    let was_empty = set.is_empty();
2241                    if reachable {
2242                        set.insert(addr_s.clone());
2243                    } else {
2244                        set.remove(&addr_s);
2245                    }
2246                    let is_empty = set.is_empty();
2247                    (was_empty != is_empty, !is_empty)
2248                };
2249                if transitioned {
2250                    let label = if becomes_reachable {
2251                        "reachable".to_string()
2252                    } else {
2253                        "private".to_string()
2254                    };
2255                    info!(reachable = %becomes_reachable, "NAT reachability changed");
2256                    let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
2257                        label,
2258                        reachable: becomes_reachable,
2259                    });
2260                }
2261            }
2262            NetworkEvent::DcutrUpgrade {
2263                remote_peer,
2264                success,
2265            } => {
2266                if success {
2267                    // Render the peer as the last 8 chars of the
2268                    // PeerId for compactness — full peer id is too long
2269                    // for a status line.
2270                    let s = remote_peer.to_base58();
2271                    let tail: String = s.chars().rev().take(8).collect::<String>()
2272                        .chars()
2273                        .rev()
2274                        .collect();
2275                    let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
2276                        peer_label: tail,
2277                    });
2278                }
2279            }
2280            NetworkEvent::InboundDial {
2281                peer_id,
2282                fingerprint,
2283                address,
2284            } => {
2285                // First: cheap server-side filters before bothering the user.
2286                if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
2287                    info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
2288                    self.network.reject_inbound(peer_id).await;
2289                    return;
2290                }
2291                // Phase E: global verified-only inbound mode. If on,
2292                // reject any unverified fingerprint without prompting.
2293                // SAS-verified (Phase G) and already-trusted (Phase A)
2294                // peers still come through.
2295                let global_verified_only =
2296                    repo::get_setting(&self.db, "verified_only_inbound")
2297                        .ok()
2298                        .flatten()
2299                        .map(|v| v == "1")
2300                        .unwrap_or(false);
2301                if global_verified_only {
2302                    let is_verified =
2303                        repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
2304                            || repo::is_fingerprint_trusted(&self.db, &fingerprint)
2305                                .unwrap_or(false);
2306                    if !is_verified {
2307                        info!(
2308                            %fingerprint,
2309                            "inbound dial auto-rejected: verified-only mode"
2310                        );
2311                        self.network.reject_inbound(peer_id).await;
2312                        return;
2313                    }
2314                }
2315                if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
2316                    info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
2317                    // Persist the address → peer_id mapping just as a
2318                    // user-dial would, so the lobby's online dot lights up.
2319                    self.connected_dial_addrs
2320                        .lock()
2321                        .unwrap()
2322                        .insert(address.to_string(), peer_id);
2323                    let _ = repo::upsert_known_peer(
2324                        &self.db,
2325                        &KnownPeer {
2326                            address: address.to_string(),
2327                            label: None,
2328                            last_connected_at: Some(now_unix()),
2329                            last_attempt_at: Some(now_unix()),
2330                            created_at: now_unix(),
2331                            fingerprint: Some(fingerprint),
2332                            trusted: true,
2333                        },
2334                    );
2335                    self.network.accept_inbound(peer_id).await;
2336                    return;
2337                }
2338                // Unknown peer — surface the modal in the TUI.
2339                let _ = self.app_event_tx.send(AppEvent::InboundDial {
2340                    peer_id,
2341                    fingerprint,
2342                    address: address.to_string(),
2343                });
2344            }
2345        }
2346    }
2347
2348    /// `verified_signer` is `Some(fp)` if this message arrived inside a
2349    /// successfully-verified `WireMessage::Signed` envelope — in which
2350    /// case the inner sender_fingerprint *must* match. `None` for
2351    /// `WireMessage::Plain`. Phase B's `OwnerGrant`/`BanMember` arms
2352    /// require it to be `Some` AND the signer to be a current owner.
2353    async fn handle_room_message(
2354        &self,
2355        room_id: &str,
2356        msg: RoomMessage,
2357        verified_signer: Option<String>,
2358    ) {
2359        let our_fp = self.identity.fingerprint().to_string();
2360        match msg {
2361            RoomMessage::MemberAnnounce {
2362                sender_fingerprint,
2363                wrapped_session_key,
2364                display_name,
2365                sender_ed25519_pubkey,
2366            } => {
2367                if sender_fingerprint == our_fp {
2368                    return;
2369                }
2370                // huddle 0.7.11: MemberAnnounce must arrive inside a
2371                // signed envelope, and the signer's fingerprint must
2372                // match the claimed announcer. Closes the TOFU-pubkey
2373                // hijack: pre-0.7.11 a malicious peer could race a
2374                // victim's first announce on a room and pin a fabricated
2375                // ed25519 pubkey under the victim's fingerprint, so honest
2376                // peers would later reject the real victim's signed
2377                // messages. Now the inner `sender_ed25519_pubkey` is
2378                // ignored — the envelope's pubkey is the authoritative one.
2379                let signer = match verified_signer {
2380                    Some(fp) => fp,
2381                    None => {
2382                        warn!(%sender_fingerprint, %room_id, "MemberAnnounce arrived unsigned; dropping");
2383                        return;
2384                    }
2385                };
2386                if signer != sender_fingerprint {
2387                    warn!(%signer, %sender_fingerprint, %room_id, "MemberAnnounce signer mismatch; dropping");
2388                    return;
2389                }
2390                // Drop announcements from banned fingerprints — they
2391                // can't rejoin until an owner unbans them (Phase B).
2392                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2393                    .unwrap_or(false)
2394                {
2395                    info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
2396                    return;
2397                }
2398                // Phase E per-room enforcement: if this room is
2399                // verified-only and the joiner isn't globally SAS-
2400                // verified, refuse to add them. The lowest-fp owner
2401                // (deterministic across honest peers) also sends a
2402                // signed `JoinRefused` so the joiner gets an explicit
2403                // message instead of a silent hang.
2404                if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
2405                    && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
2406                {
2407                    info!(
2408                        %sender_fingerprint, %room_id,
2409                        "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
2410                    );
2411                    let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
2412                    let lowest_owner = owners.iter().min().cloned();
2413                    if lowest_owner.as_deref() == Some(&our_fp) {
2414                        let msg = RoomMessage::JoinRefused {
2415                            room_id: room_id.to_string(),
2416                            target_fingerprint: sender_fingerprint.clone(),
2417                            reason: "room requires SAS verification — ask an existing member to verify you".into(),
2418                        };
2419                        if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
2420                            if let Ok(bytes) =
2421                                crate::network::protocol::encode_wire_signed(&env)
2422                            {
2423                                self.network
2424                                    .publish_room_message(room_id.to_string(), bytes)
2425                                    .await;
2426                            }
2427                        }
2428                    }
2429                    return;
2430                }
2431                let need_inbound = {
2432                    let mut rooms = self.active_rooms.lock().unwrap();
2433                    let room = match rooms.get_mut(room_id) {
2434                        Some(r) => r,
2435                        None => return,
2436                    };
2437                    // huddle 0.7: Direct rooms are 1-1 forever. If a
2438                    // third fingerprint announces, drop it locally and
2439                    // skip the persist/wrap-session path. This is honest-
2440                    // client enforcement — a malicious peer with the
2441                    // canonical DM passphrase-equivalent could still
2442                    // chat, but they'd never be visible in our sidebar
2443                    // or render in the DM pane.
2444                    if room.info.kind == RoomKind::Direct
2445                        && !room.members.contains(&sender_fingerprint)
2446                        && room.members.len() >= 2
2447                    {
2448                        info!(
2449                            %sender_fingerprint, %room_id,
2450                            "dropping MemberAnnounce on Direct room: already at 2-member cap"
2451                        );
2452                        return;
2453                    }
2454                    let newly_added = room.members.insert(sender_fingerprint.clone());
2455                    if newly_added {
2456                        let _ = self.app_event_tx.send(AppEvent::MemberJoined {
2457                            room_id: room_id.to_string(),
2458                            fingerprint: sender_fingerprint.clone(),
2459                        });
2460                    }
2461                    // Persist member with optional display name + pubkey.
2462                    // `ed25519_pubkey` is `None` for pre-0.3 peers; the
2463                    // upsert COALESCEs so once we learn it we never lose
2464                    // it on a later announce that drops the field.
2465                    let _ = repo::upsert_room_member(
2466                        &self.db,
2467                        &StoredRoomMember {
2468                            room_id: room_id.to_string(),
2469                            peer_id: String::new(), // unknown at this layer
2470                            fingerprint: sender_fingerprint.clone(),
2471                            last_seen: Some(now_unix()),
2472                            verified: false,
2473                            ed25519_pubkey: sender_ed25519_pubkey.clone(),
2474                            // Role is set on first insert only — the
2475                            // upsert ON CONFLICT clause preserves an
2476                            // existing 'owner' on re-announce. A genuine
2477                            // new fingerprint is a 'member' until an
2478                            // OwnerGrant lands.
2479                            role: "member".into(),
2480                        },
2481                    );
2482                    if let Some(name) = display_name.as_deref() {
2483                        let _ = repo::set_member_display_name(
2484                            &self.db,
2485                            room_id,
2486                            &sender_fingerprint,
2487                            Some(name),
2488                        );
2489                    }
2490                    room.info.encrypted && wrapped_session_key.is_some()
2491                };
2492
2493                // huddle 0.7.1: for Direct rooms, the passphrase_key is
2494                // derived from ECDH between our identity key and the
2495                // partner's. The partner's pubkey may arrive in *this*
2496                // MemberAnnounce — so we lazily compute the key now,
2497                // before the unwrap path runs. Idempotent: if we
2498                // already have the key, this is a no-op.
2499                if matches!(
2500                    self.active_rooms
2501                        .lock()
2502                        .unwrap()
2503                        .get(room_id)
2504                        .map(|r| (r.info.kind, r.passphrase_key.is_none())),
2505                    Some((RoomKind::Direct, true))
2506                ) {
2507                    if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
2508                        if let Some(key) =
2509                            self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
2510                        {
2511                            let mut rooms = self.active_rooms.lock().unwrap();
2512                            if let Some(room) = rooms.get_mut(room_id) {
2513                                room.passphrase_key = Some(key);
2514                            }
2515                            drop(rooms);
2516                            // We just got the key — re-broadcast our
2517                            // MemberAnnounce so the partner gets our
2518                            // wrapped session key. Fire-and-forget;
2519                            // failures are logged.
2520                            let app = self.clone();
2521                            let rid = room_id.to_string();
2522                            tokio::spawn(async move {
2523                                if let Err(e) = app.broadcast_member_announce(&rid).await {
2524                                    warn!(%e, "re-broadcast DM announce after key derivation");
2525                                }
2526                            });
2527                        }
2528                    }
2529                }
2530
2531                if need_inbound {
2532                    let wrapped = wrapped_session_key.unwrap();
2533                    let result = {
2534                        let mut rooms = self.active_rooms.lock().unwrap();
2535                        let room = rooms.get_mut(room_id).unwrap();
2536                        let passphrase_key = match &room.passphrase_key {
2537                            Some(k) => k,
2538                            None => {
2539                                warn!("no passphrase key when receiving session key");
2540                                return;
2541                            }
2542                        };
2543                        match passphrase::unwrap(&wrapped, passphrase_key) {
2544                            Ok(plain) => match String::from_utf8(plain) {
2545                                Ok(key_b64) => {
2546                                    let crypto = room.crypto.as_mut().unwrap();
2547                                    crypto.add_inbound_session(&sender_fingerprint, &key_b64)
2548                                }
2549                                Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
2550                            },
2551                            Err(e) => Err(e),
2552                        }
2553                    };
2554                    if let Err(e) = result {
2555                        error!(%e, "add inbound session failed");
2556                    }
2557                }
2558            }
2559            RoomMessage::SessionKeyRequest {
2560                requester_fingerprint,
2561            } => {
2562                if requester_fingerprint == our_fp {
2563                    return;
2564                }
2565                // Re-announce ourselves to share our session key with the new joiner.
2566                if let Err(e) = self.broadcast_member_announce(room_id).await {
2567                    warn!(%e, "broadcast member announce on request");
2568                }
2569            }
2570            RoomMessage::Encrypted {
2571                sender_fingerprint,
2572                session_id,
2573                ciphertext_b64,
2574            } => {
2575                if sender_fingerprint == our_fp {
2576                    return;
2577                }
2578                // huddle 0.7.11: ban filter on every content-bearing arm.
2579                // Pre-0.7.11 only MemberAnnounce was filtered, so banned
2580                // peers could still post Encrypted/Plain after a kick
2581                // (cosmetically in encrypted rooms post-rotation since
2582                // they have no inbound session, but in unencrypted rooms
2583                // their plaintext rendered freely — see RoomMessage::Plain
2584                // arm below).
2585                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2586                    .unwrap_or(false)
2587                {
2588                    debug!(%sender_fingerprint, %room_id, "dropping Encrypted from banned peer");
2589                    return;
2590                }
2591                let ct_bytes = match base64::Engine::decode(
2592                    &base64::engine::general_purpose::STANDARD,
2593                    &ciphertext_b64,
2594                ) {
2595                    Ok(b) => b,
2596                    Err(e) => {
2597                        warn!(%e, "bad base64 ciphertext");
2598                        return;
2599                    }
2600                };
2601                let plaintext = {
2602                    let mut rooms = self.active_rooms.lock().unwrap();
2603                    let room = match rooms.get_mut(room_id) {
2604                        Some(r) => r,
2605                        None => return,
2606                    };
2607                    let crypto = match room.crypto.as_mut() {
2608                        Some(c) => c,
2609                        None => return,
2610                    };
2611                    crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
2612                };
2613                match plaintext {
2614                    Ok(pt) => {
2615                        let body = String::from_utf8_lossy(&pt).to_string();
2616                        let sent_at = now_unix();
2617                        let _ = repo::insert_room_message(
2618                            &self.db,
2619                            room_id,
2620                            &sender_fingerprint,
2621                            "in",
2622                            &body,
2623                            sent_at,
2624                        );
2625                        let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2626                        self.maybe_emit_mention(room_id, &body);
2627                        let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2628                            room_id: room_id.to_string(),
2629                            sender_fingerprint,
2630                            body,
2631                            sent_at,
2632                        });
2633                    }
2634                    Err(e) => {
2635                        debug!(%e, "decrypt failed (probably missing session key)");
2636                    }
2637                }
2638            }
2639            RoomMessage::Plain {
2640                sender_fingerprint,
2641                body,
2642            } => {
2643                if sender_fingerprint == our_fp {
2644                    return;
2645                }
2646                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2647                    .unwrap_or(false)
2648                {
2649                    debug!(%sender_fingerprint, %room_id, "dropping Plain from banned peer");
2650                    return;
2651                }
2652                let sent_at = now_unix();
2653                let _ = repo::insert_room_message(
2654                    &self.db,
2655                    room_id,
2656                    &sender_fingerprint,
2657                    "in",
2658                    &body,
2659                    sent_at,
2660                );
2661                let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
2662                self.maybe_emit_mention(room_id, &body);
2663                let _ = self.app_event_tx.send(AppEvent::MessageReceived {
2664                    room_id: room_id.to_string(),
2665                    sender_fingerprint,
2666                    body,
2667                    sent_at,
2668                });
2669            }
2670            RoomMessage::Typing { sender_fingerprint } => {
2671                if sender_fingerprint == our_fp {
2672                    return;
2673                }
2674                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2675                    .unwrap_or(false)
2676                {
2677                    return;
2678                }
2679                let expiry = now_unix() + TYPING_TTL_SECS;
2680                let mut rooms = self.active_rooms.lock().unwrap();
2681                if let Some(room) = rooms.get_mut(room_id) {
2682                    room.typers.insert(sender_fingerprint, expiry);
2683                }
2684                drop(rooms);
2685                let _ = self.app_event_tx.send(AppEvent::TypingChanged {
2686                    room_id: room_id.to_string(),
2687                });
2688            }
2689            RoomMessage::RotateRoomKey {
2690                rotator_fingerprint,
2691                new_salt,
2692            } => {
2693                if rotator_fingerprint == our_fp {
2694                    return;
2695                }
2696                // Rotations are self-attested: the signer must be the
2697                // claimed rotator. Unsigned forgeries land in
2698                // `verified_signer = None` and are dropped here, as are
2699                // signed envelopes where the signer fp doesn't match.
2700                let signer = match verified_signer {
2701                    Some(fp) => fp,
2702                    None => {
2703                        warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
2704                        return;
2705                    }
2706                };
2707                if signer != rotator_fingerprint {
2708                    warn!(
2709                        %signer, %rotator_fingerprint, %room_id,
2710                        "RotateRoomKey signer mismatch with claimed rotator; dropping"
2711                    );
2712                    return;
2713                }
2714                let _ = self.app_event_tx.send(AppEvent::RotationRequested {
2715                    room_id: room_id.to_string(),
2716                    rotator_fingerprint,
2717                    new_salt,
2718                });
2719            }
2720            RoomMessage::MemberLeave { sender_fingerprint } => {
2721                if sender_fingerprint == our_fp {
2722                    return;
2723                }
2724                // huddle 0.7.11: MemberLeave must arrive inside a signed
2725                // envelope whose signer matches the claimed leaver.
2726                // Pre-0.7.11 plain leaves and forged leaves are dropped.
2727                let signer = match verified_signer {
2728                    Some(fp) => fp,
2729                    None => {
2730                        warn!(%sender_fingerprint, %room_id, "MemberLeave arrived unsigned; dropping");
2731                        return;
2732                    }
2733                };
2734                if signer != sender_fingerprint {
2735                    warn!(%signer, %sender_fingerprint, %room_id, "MemberLeave signer mismatch; dropping");
2736                    return;
2737                }
2738                let removed = {
2739                    let mut rooms = self.active_rooms.lock().unwrap();
2740                    if let Some(room) = rooms.get_mut(room_id) {
2741                        room.members.remove(&sender_fingerprint)
2742                    } else {
2743                        false
2744                    }
2745                };
2746                if removed {
2747                    let _ = self.app_event_tx.send(AppEvent::MemberLeft {
2748                        room_id: room_id.to_string(),
2749                        fingerprint: sender_fingerprint,
2750                    });
2751                }
2752            }
2753            RoomMessage::FileOffer {
2754                sender_fingerprint,
2755                file_id,
2756                name,
2757                size_bytes,
2758                mime,
2759                chunk_count,
2760                encrypted_meta,
2761            } => {
2762                if sender_fingerprint == our_fp {
2763                    return; // ignore our own broadcast
2764                }
2765                // huddle 0.7.11: FileOffer must be signed so peers can't
2766                // spoof attribution. The chunk stream itself stays plain
2767                // (sha256 over the assembly is the integrity gate), but
2768                // who *announced* the file is now bound to the signer.
2769                let signer = match verified_signer {
2770                    Some(fp) => fp,
2771                    None => {
2772                        warn!(%sender_fingerprint, %room_id, %file_id, "FileOffer arrived unsigned; dropping");
2773                        return;
2774                    }
2775                };
2776                if signer != sender_fingerprint {
2777                    warn!(%signer, %sender_fingerprint, %room_id, %file_id, "FileOffer signer mismatch; dropping");
2778                    return;
2779                }
2780                // Drop offers from banned peers in the same shape as
2781                // MemberAnnounce — keeps moderation invariant tight.
2782                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2783                    .unwrap_or(false)
2784                {
2785                    info!(%sender_fingerprint, %room_id, %file_id, "dropping FileOffer from banned peer");
2786                    return;
2787                }
2788                self.handle_file_offer(
2789                    room_id,
2790                    sender_fingerprint,
2791                    file_id,
2792                    name,
2793                    size_bytes,
2794                    mime,
2795                    chunk_count,
2796                    encrypted_meta,
2797                );
2798            }
2799            RoomMessage::FileChunk {
2800                sender_fingerprint,
2801                file_id,
2802                chunk_index,
2803                total_chunks,
2804                data_b64,
2805            } => {
2806                if sender_fingerprint == our_fp {
2807                    return;
2808                }
2809                if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
2810                    .unwrap_or(false)
2811                {
2812                    return;
2813                }
2814                self.handle_file_chunk(
2815                    room_id,
2816                    sender_fingerprint,
2817                    file_id,
2818                    chunk_index,
2819                    total_chunks,
2820                    data_b64,
2821                );
2822            }
2823            RoomMessage::OwnerGrant {
2824                room_id: announced_room_id,
2825                target_fingerprint,
2826            } => {
2827                // Both: payload room_id must match the topic's room_id
2828                // (no cross-room replay), AND the signer must be a
2829                // current owner of this room. Unsigned forgeries land in
2830                // `verified_signer = None` and are dropped here.
2831                if announced_room_id != room_id {
2832                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
2833                    return;
2834                }
2835                let signer = match verified_signer {
2836                    Some(fp) => fp,
2837                    None => {
2838                        warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
2839                        return;
2840                    }
2841                };
2842                if !self.is_owner(room_id, &signer) {
2843                    warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
2844                    return;
2845                }
2846                info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
2847                if let Err(e) =
2848                    repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
2849                {
2850                    warn!(%e, "OwnerGrant: set_member_role failed");
2851                }
2852            }
2853            RoomMessage::BanMember {
2854                room_id: announced_room_id,
2855                target_fingerprint,
2856            } => {
2857                if announced_room_id != room_id {
2858                    warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
2859                    return;
2860                }
2861                let signer = match verified_signer {
2862                    Some(fp) => fp,
2863                    None => {
2864                        warn!(%room_id, "BanMember arrived unsigned; dropping");
2865                        return;
2866                    }
2867                };
2868                if !self.is_owner(room_id, &signer) {
2869                    warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
2870                    return;
2871                }
2872                if target_fingerprint == our_fp {
2873                    // We've been kicked. Locally evict ourselves so the
2874                    // TUI tabs close; the kicker's subsequent
2875                    // RotateRoomKey will arrive separately and we
2876                    // simply won't be able to decrypt the new key,
2877                    // matching the "soft kick" semantics.
2878                    info!(%room_id, %signer, "we were kicked from this room");
2879                    self.active_rooms.lock().unwrap().remove(room_id);
2880                    let _ = self.app_event_tx.send(AppEvent::RoomLeft {
2881                        room_id: room_id.to_string(),
2882                    });
2883                    return;
2884                }
2885                info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
2886                if let Err(e) = repo::add_room_ban(
2887                    &self.db,
2888                    room_id,
2889                    &target_fingerprint,
2890                    &signer,
2891                    "", // signature lives in the envelope, not the row
2892                    now_unix(),
2893                ) {
2894                    warn!(%e, "BanMember: add_room_ban failed");
2895                }
2896                self.evict_banned_member(room_id, &target_fingerprint);
2897            }
2898            RoomMessage::SasInit {
2899                tx_id,
2900                ephemeral_x25519_pubkey_b64,
2901                target_fingerprint,
2902            } => {
2903                if target_fingerprint != our_fp {
2904                    // Not addressed to us — ignore. Phase G is point-
2905                    // to-point even though it travels over the room
2906                    // topic, so members of the room who aren't the
2907                    // target don't need to act.
2908                    return;
2909                }
2910                let signer = match verified_signer {
2911                    Some(fp) => fp,
2912                    None => {
2913                        warn!("SasInit arrived unsigned; dropping");
2914                        return;
2915                    }
2916                };
2917                let their_pub =
2918                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2919                        Ok(pk) => pk,
2920                        Err(e) => {
2921                            warn!(%e, "SasInit: bad x25519 pubkey");
2922                            return;
2923                        }
2924                    };
2925                let tx_id_bytes = match B64.decode(&tx_id) {
2926                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2927                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2928                        arr.copy_from_slice(&b);
2929                        arr
2930                    }
2931                    _ => {
2932                        warn!(%tx_id, "SasInit: bad tx_id length");
2933                        return;
2934                    }
2935                };
2936                let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
2937                let sas_code =
2938                    crate::crypto::sas::derive_sas_code(&our_secret, &their_pub, &tx_id_bytes);
2939                self.sas_flows.lock().unwrap().insert(
2940                    tx_id.clone(),
2941                    SasFlow {
2942                        room_id: room_id.to_string(),
2943                        partner_fingerprint: signer.clone(),
2944                        our_secret,
2945                        sas_code: Some(sas_code.clone()),
2946                        our_confirmed: false,
2947                        their_confirmed: false,
2948                        finalized: false,
2949                    },
2950                );
2951                // Respond with our pubkey so the initiator can compute
2952                // the same code.
2953                let response = RoomMessage::SasResponse {
2954                    tx_id: tx_id.clone(),
2955                    ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
2956                };
2957                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
2958                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
2959                        self.network
2960                            .publish_room_message(room_id.to_string(), bytes)
2961                            .await;
2962                    }
2963                }
2964                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
2965                    room_id: room_id.to_string(),
2966                    partner_fingerprint: signer,
2967                    tx_id,
2968                    emoji_string: sas_code.emoji_string(),
2969                    emoji_labels: sas_code.emoji_labels(),
2970                    decimal: sas_code.decimal,
2971                });
2972            }
2973            RoomMessage::SasResponse {
2974                tx_id,
2975                ephemeral_x25519_pubkey_b64,
2976            } => {
2977                let signer = match verified_signer {
2978                    Some(fp) => fp,
2979                    None => {
2980                        warn!("SasResponse arrived unsigned; dropping");
2981                        return;
2982                    }
2983                };
2984                let their_pub =
2985                    match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
2986                        Ok(pk) => pk,
2987                        Err(e) => {
2988                            warn!(%e, "SasResponse: bad x25519 pubkey");
2989                            return;
2990                        }
2991                    };
2992                let tx_id_bytes = match B64.decode(&tx_id) {
2993                    Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
2994                        let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
2995                        arr.copy_from_slice(&b);
2996                        arr
2997                    }
2998                    _ => return,
2999                };
3000                let emit = {
3001                    let mut flows = self.sas_flows.lock().unwrap();
3002                    let flow = match flows.get_mut(&tx_id) {
3003                        Some(f) => f,
3004                        None => {
3005                            warn!(%tx_id, "SasResponse for unknown tx_id");
3006                            return;
3007                        }
3008                    };
3009                    if flow.partner_fingerprint != signer {
3010                        warn!(
3011                            expected = %flow.partner_fingerprint, got = %signer,
3012                            "SasResponse signer doesn't match flow's partner; dropping"
3013                        );
3014                        return;
3015                    }
3016                    let code = crate::crypto::sas::derive_sas_code(
3017                        &flow.our_secret,
3018                        &their_pub,
3019                        &tx_id_bytes,
3020                    );
3021                    flow.sas_code = Some(code.clone());
3022                    code
3023                };
3024                let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3025                    room_id: room_id.to_string(),
3026                    partner_fingerprint: signer,
3027                    tx_id,
3028                    emoji_string: emit.emoji_string(),
3029                    emoji_labels: emit.emoji_labels(),
3030                    decimal: emit.decimal,
3031                });
3032            }
3033            RoomMessage::CodeJoinRequest {
3034                room_id: announced_room_id,
3035                joiner_x25519_pubkey_b64,
3036                code,
3037            } => {
3038                if announced_room_id != room_id {
3039                    return;
3040                }
3041                let joiner_fp = match verified_signer {
3042                    Some(fp) => fp,
3043                    None => {
3044                        warn!("CodeJoinRequest unsigned; dropping");
3045                        return;
3046                    }
3047                };
3048                // Only owners with an active code are interested in
3049                // responding. Other peers (incl. non-issuing owners)
3050                // simply ignore.
3051                let our_fp = self.identity.fingerprint().to_string();
3052                if !self.is_owner(room_id, &our_fp) {
3053                    return;
3054                }
3055                // Match + consume the code. Single use.
3056                let now = now_unix();
3057                let (code_ok, our_session_id, wrap_input) = {
3058                    let mut rooms = self.active_rooms.lock().unwrap();
3059                    let room = match rooms.get_mut(room_id) {
3060                        Some(r) => r,
3061                        None => return,
3062                    };
3063                    if room.passphrase_key.is_none() {
3064                        warn!("CodeJoinRequest: no passphrase key locally; can't respond");
3065                        return;
3066                    }
3067                    let original_len = room.issued_codes.len();
3068                    room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
3069                    let matched = room.issued_codes.len() < original_len;
3070                    if !matched {
3071                        info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
3072                        return;
3073                    }
3074                    let crypto = room.crypto.as_ref().unwrap();
3075                    (
3076                        true,
3077                        crypto.our_session_id(),
3078                        crypto.our_session_key_b64(),
3079                    )
3080                };
3081                let _ = code_ok;
3082                // ECDH with the joiner's ephemeral pubkey.
3083                let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
3084                    Ok(pk) => pk,
3085                    Err(e) => {
3086                        warn!(%e, "CodeJoinRequest: bad pubkey");
3087                        return;
3088                    }
3089                };
3090                use x25519_dalek::{PublicKey, StaticSecret};
3091                let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3092                let our_pub = PublicKey::from(&our_secret);
3093                let shared = our_secret.diffie_hellman(&their_pub);
3094                // HKDF the shared secret into a 32-byte wrap key.
3095                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3096                let mut wrap_key = [0u8; passphrase::KEY_LEN];
3097                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3098                    .expect("32 bytes is within HKDF limits");
3099                // Wrap our session key under the ECDH-derived key,
3100                // reusing the existing AEAD primitives.
3101                let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
3102                    Ok(w) => w,
3103                    Err(e) => {
3104                        warn!(%e, "CodeJoinRequest: wrap failed");
3105                        return;
3106                    }
3107                };
3108                let response = RoomMessage::CodeJoinResponse {
3109                    room_id: room_id.to_string(),
3110                    target_fingerprint: joiner_fp.clone(),
3111                    owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3112                    owner_session_id: our_session_id,
3113                    wrapped_session_key_b64: wrapped,
3114                    nonce_b64: String::new(), // nonce is embedded in `wrapped` per passphrase::wrap
3115                };
3116                if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
3117                    if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3118                        self.network
3119                            .publish_room_message(room_id.to_string(), bytes)
3120                            .await;
3121                    }
3122                }
3123                info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
3124            }
3125            RoomMessage::CodeJoinResponse {
3126                room_id: announced_room_id,
3127                target_fingerprint,
3128                owner_x25519_pubkey_b64,
3129                owner_session_id,
3130                wrapped_session_key_b64,
3131                nonce_b64: _,
3132            } => {
3133                if announced_room_id != room_id || target_fingerprint != our_fp {
3134                    return;
3135                }
3136                let owner_fp = match verified_signer {
3137                    Some(fp) => fp,
3138                    None => {
3139                        warn!("CodeJoinResponse unsigned; dropping");
3140                        return;
3141                    }
3142                };
3143                let our_secret = match self
3144                    .pending_code_secrets
3145                    .lock()
3146                    .unwrap()
3147                    .remove(&(room_id.to_string(), our_fp.clone()))
3148                {
3149                    Some(s) => s,
3150                    None => {
3151                        warn!(%room_id, "CodeJoinResponse with no pending code-join state");
3152                        return;
3153                    }
3154                };
3155                let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
3156                    Ok(pk) => pk,
3157                    Err(e) => {
3158                        warn!(%e, "CodeJoinResponse: bad owner pubkey");
3159                        return;
3160                    }
3161                };
3162                let shared = our_secret.diffie_hellman(&owner_pub);
3163                let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
3164                let mut wrap_key = [0u8; passphrase::KEY_LEN];
3165                hk.expand(b"huddle-code-join-v1", &mut wrap_key)
3166                    .expect("32 bytes within HKDF limits");
3167                let session_key_bytes =
3168                    match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
3169                        Ok(b) => b,
3170                        Err(e) => {
3171                            warn!(%e, "CodeJoinResponse: unwrap failed");
3172                            return;
3173                        }
3174                    };
3175                let session_key_str = match String::from_utf8(session_key_bytes) {
3176                    Ok(s) => s,
3177                    Err(e) => {
3178                        warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
3179                        return;
3180                    }
3181                };
3182                // Install as an inbound session keyed by the owner's fp.
3183                let mut rooms = self.active_rooms.lock().unwrap();
3184                if let Some(room) = rooms.get_mut(room_id) {
3185                    if let Some(crypto) = room.crypto.as_mut() {
3186                        if let Err(e) =
3187                            crypto.add_inbound_session(&owner_fp, &session_key_str)
3188                        {
3189                            warn!(%e, "CodeJoinResponse: add_inbound_session failed");
3190                        } else {
3191                            info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
3192                            room.members.insert(owner_fp.clone());
3193                            let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3194                                room_id: room_id.to_string(),
3195                                fingerprint: owner_fp,
3196                            });
3197                        }
3198                    }
3199                }
3200            }
3201            RoomMessage::JoinRefused {
3202                room_id: announced_room_id,
3203                target_fingerprint,
3204                reason,
3205            } => {
3206                if announced_room_id != room_id || target_fingerprint != our_fp {
3207                    return;
3208                }
3209                // Surface the refusal as an Error so the user sees why
3210                // their join didn't take. The Phase 3 modal-queue rule
3211                // means this won't clobber typing in another modal.
3212                let _ = self.app_event_tx.send(AppEvent::Error {
3213                    description: format!("join refused: {reason}"),
3214                });
3215            }
3216            RoomMessage::SasConfirm { tx_id, matched } => {
3217                let signer = match verified_signer {
3218                    Some(fp) => fp,
3219                    None => return,
3220                };
3221                let (room_id_done, partner_fp_done, both_done) = {
3222                    let mut flows = self.sas_flows.lock().unwrap();
3223                    let flow = match flows.get_mut(&tx_id) {
3224                        Some(f) => f,
3225                        None => return,
3226                    };
3227                    if flow.partner_fingerprint != signer {
3228                        return;
3229                    }
3230                    if !matched {
3231                        // Partner declined / mismatch — drop the flow.
3232                        let _ = flow;
3233                        flows.remove(&tx_id);
3234                        return;
3235                    }
3236                    flow.their_confirmed = true;
3237                    // huddle 0.7.11: only fire finalize from this arm
3238                    // when the flow hasn't already been finalized by
3239                    // the local `sas_match` path. The `finalized`
3240                    // latch is set inside `finish_sas` (taken under
3241                    // this same Mutex), so the two paths can't both
3242                    // observe it as `false`.
3243                    if flow.our_confirmed && flow.their_confirmed && !flow.finalized {
3244                        flow.finalized = true;
3245                        (
3246                            Some(flow.room_id.clone()),
3247                            Some(flow.partner_fingerprint.clone()),
3248                            true,
3249                        )
3250                    } else {
3251                        (None, None, false)
3252                    }
3253                };
3254                if both_done {
3255                    if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
3256                        if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
3257                            warn!(%e, "finish_sas failed");
3258                        }
3259                    }
3260                }
3261            }
3262            RoomMessage::ProfileUpdate {
3263                sender_fingerprint,
3264                username,
3265                updated_at,
3266            } => {
3267                // huddle 0.5: username spoof defense. Drop any
3268                // ProfileUpdate that didn't arrive inside a Signed
3269                // envelope, or whose signer doesn't match the claimed
3270                // sender_fingerprint. Without this anyone could pretend
3271                // to be "alice" by stuffing the field.
3272                let signer = match verified_signer {
3273                    Some(fp) => fp,
3274                    None => {
3275                        warn!(
3276                            sender = %sender_fingerprint,
3277                            "dropping unsigned ProfileUpdate"
3278                        );
3279                        return;
3280                    }
3281                };
3282                if signer != sender_fingerprint {
3283                    warn!(
3284                        signer = %signer,
3285                        claimed = %sender_fingerprint,
3286                        "dropping ProfileUpdate with signer != sender"
3287                    );
3288                    return;
3289                }
3290                if let Err(e) = repo::upsert_peer_profile(
3291                    &self.db,
3292                    &sender_fingerprint,
3293                    username.as_deref(),
3294                    updated_at,
3295                ) {
3296                    warn!(%e, "upsert_peer_profile failed");
3297                    return;
3298                }
3299                let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
3300                    fingerprint: sender_fingerprint,
3301                    username,
3302                });
3303            }
3304        }
3305    }
3306
3307    // -------------------------------------------------------------------
3308    // File transfer — public API
3309    // -------------------------------------------------------------------
3310
3311    /// Send a local file to a room. Reads the file, optionally encrypts
3312    /// it for encrypted rooms, chunks it, broadcasts a FileOffer then
3313    /// each FileChunk. Returns the file_id once all chunks are queued.
3314    pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
3315        let bytes = std::fs::read(path)?;
3316        let name = path
3317            .file_name()
3318            .map(|n| n.to_string_lossy().to_string())
3319            .unwrap_or_else(|| "untitled".into());
3320        let mime = crate::files::guess_mime(&name);
3321        let original_path = path.to_path_buf();
3322
3323        let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
3324            let mut rooms = self.active_rooms.lock().unwrap();
3325            let room = rooms
3326                .get_mut(room_id)
3327                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3328            // huddle 0.7.11: read-only joiners (code-joined peers) cannot
3329            // send files. Mirrors the check in send_room_message; without
3330            // it, code-joined peers could broadcast FileOffer/FileChunk
3331            // even though existing members ignore their chat messages.
3332            if room.read_only {
3333                return Err(HuddleError::Other(
3334                    "this room is read-only — you can't send files".into(),
3335                ));
3336            }
3337            if room.info.encrypted {
3338                let crypto = room
3339                    .crypto
3340                    .as_mut()
3341                    .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
3342                let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
3343                (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
3344            } else {
3345                (false, None, None, bytes)
3346            }
3347        };
3348        let _ = &mut maybe_session_id; // silence unused warning when non-encrypted
3349
3350        let plan =
3351            self.file_manager
3352                .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
3353        let file_id = plan.file_id.clone();
3354        let total = plan.chunks.len() as u32;
3355        let our_fp = self.identity.fingerprint().to_string();
3356
3357        let attachment = StoredAttachment {
3358            id: 0,
3359            room_id: room_id.to_string(),
3360            message_id: None,
3361            sender_fingerprint: our_fp.clone(),
3362            file_id: file_id.clone(),
3363            name: name.clone(),
3364            mime: mime.clone(),
3365            size_bytes: plan.size_bytes as i64,
3366            status: AttachmentStatus::Ready,
3367            cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
3368            saved_path: Some(original_path.to_string_lossy().into()),
3369            error: None,
3370            encrypted: room_encrypted,
3371            wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
3372            nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
3373            megolm_session_id: encrypted_meta_opt
3374                .as_ref()
3375                .map(|m| m.megolm_session_id.clone()),
3376            content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
3377            created_at: now_unix(),
3378        };
3379        repo::upsert_attachment(&self.db, &attachment)?;
3380        let _ = self.app_event_tx.send(AppEvent::FileOffered {
3381            room_id: room_id.to_string(),
3382            file_id: file_id.clone(),
3383            name: name.clone(),
3384            size_bytes: plan.size_bytes,
3385            sender_fingerprint: our_fp.clone(),
3386        });
3387
3388        // Publish the offer. huddle 0.7.11: FileOffer is now signed so
3389        // peers can't announce a file in someone else's name (attribution
3390        // spoof). FileChunks themselves stay plain — the receiver
3391        // assembles by chunk-index and verifies SHA-256 against
3392        // `file_id`, so spoofed chunks waste bandwidth but can't smuggle
3393        // mismatched bytes through the hash gate.
3394        let offer = RoomMessage::FileOffer {
3395            sender_fingerprint: our_fp.clone(),
3396            file_id: file_id.clone(),
3397            name,
3398            size_bytes: plan.size_bytes,
3399            mime,
3400            chunk_count: total,
3401            encrypted_meta: encrypted_meta_opt,
3402        };
3403        if let Ok(env) = crate::crypto::sign_message(&self.identity, &offer) {
3404            if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3405                self.network
3406                    .publish_room_message(room_id.to_string(), bytes)
3407                    .await;
3408            }
3409        }
3410
3411        // Stream chunks. Brief pacing so gossipsub doesn't see a thundering
3412        // herd from a single peer.
3413        let net = self.network.clone();
3414        let room = room_id.to_string();
3415        let our = our_fp.clone();
3416        let fid = file_id.clone();
3417        let chunks = plan.chunks.clone();
3418        tokio::spawn(async move {
3419            for (i, data) in chunks.iter().enumerate() {
3420                let msg = RoomMessage::FileChunk {
3421                    sender_fingerprint: our.clone(),
3422                    file_id: fid.clone(),
3423                    chunk_index: i as u32,
3424                    total_chunks: total,
3425                    data_b64: B64.encode(data),
3426                };
3427                if let Ok(bytes) = encode_wire(&msg) {
3428                    net.publish_room_message(room.clone(), bytes).await;
3429                }
3430                tokio::time::sleep(Duration::from_millis(40)).await;
3431            }
3432        });
3433
3434        Ok(file_id)
3435    }
3436
3437    /// Save a completed/ready attachment to the user's Downloads folder.
3438    /// Decrypts encrypted attachments on the way out.
3439    pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
3440        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3441            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3442        if !matches!(
3443            attachment.status,
3444            AttachmentStatus::Ready | AttachmentStatus::Saved
3445        ) {
3446            return Err(HuddleError::Other(format!(
3447                "attachment is not ready (status={})",
3448                attachment.status.as_str()
3449            )));
3450        }
3451        // Our own encrypted attachment: the file_manager cache holds the
3452        // ciphertext and we have no inbound Megolm session keyed by
3453        // ourselves, so it can't be decrypted back. But `saved_path` still
3454        // points at the original plaintext we sent — copy from there.
3455        let plaintext = if attachment.encrypted
3456            && attachment.sender_fingerprint == self.identity.fingerprint()
3457        {
3458            match attachment
3459                .saved_path
3460                .as_deref()
3461                .filter(|p| Path::new(p).exists())
3462            {
3463                Some(src) => std::fs::read(src)?,
3464                None => {
3465                    return Err(HuddleError::Other(
3466                        "your original file has moved or been deleted — it can't be \
3467                         recovered from the encrypted cache"
3468                            .into(),
3469                    ));
3470                }
3471            }
3472        } else {
3473            let cached = self.file_manager.read_cache(file_id)?;
3474            if attachment.encrypted {
3475                let meta = EncryptedFileMeta {
3476                    megolm_session_id: attachment
3477                        .megolm_session_id
3478                        .clone()
3479                        .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
3480                    wrapped_key_b64: attachment
3481                        .wrapped_key
3482                        .clone()
3483                        .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
3484                    nonce_b64: attachment
3485                        .nonce
3486                        .clone()
3487                        .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
3488                    content_hash: attachment
3489                        .content_hash
3490                        .clone()
3491                        .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
3492                };
3493                self.decrypt_attachment(
3494                    room_id,
3495                    &attachment.sender_fingerprint,
3496                    &cached,
3497                    &meta,
3498                )?
3499            } else {
3500                cached
3501            }
3502        };
3503        let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
3504        repo::update_attachment_paths(
3505            &self.db,
3506            room_id,
3507            file_id,
3508            None,
3509            Some(&saved.to_string_lossy()),
3510        )?;
3511        repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
3512        let _ = self.app_event_tx.send(AppEvent::FileSaved {
3513            file_id: file_id.into(),
3514            path: saved.to_string_lossy().into(),
3515        });
3516        Ok(saved)
3517    }
3518
3519    /// Drop any in-flight chunks and remove the attachment row.
3520    pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
3521        self.file_manager.cancel_incoming(file_id);
3522        repo::update_attachment_status(
3523            &self.db,
3524            room_id,
3525            file_id,
3526            AttachmentStatus::Cancelled,
3527            None,
3528        )?;
3529        Ok(())
3530    }
3531
3532    /// Launch the system's default opener on a saved file.
3533    pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
3534        let attachment = repo::get_attachment(&self.db, room_id, file_id)?
3535            .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
3536        let path = attachment
3537            .saved_path
3538            .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
3539        open_with_system(&path)
3540    }
3541
3542    pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
3543        repo::list_room_attachments(&self.db, room_id)
3544    }
3545
3546    /// Mark a peer's fingerprint as verified in the given room. Used by
3547    /// the `^V` verification modal after the user has compared the
3548    /// fingerprint out-of-band.
3549    pub fn set_member_verified(
3550        &self,
3551        room_id: &str,
3552        fingerprint: &str,
3553        verified: bool,
3554    ) -> Result<()> {
3555        // Make sure there's a member row to flip — peer_id is unknown
3556        // at this layer when the user verifies an out-of-band identity,
3557        // so we use the fingerprint as the canonical identity key with
3558        // an empty peer_id placeholder if none exists.
3559        let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
3560        if !members.iter().any(|m| m.fingerprint == fingerprint) {
3561            repo::upsert_room_member(
3562                &self.db,
3563                &StoredRoomMember {
3564                    room_id: room_id.to_string(),
3565                    peer_id: String::new(),
3566                    fingerprint: fingerprint.to_string(),
3567                    last_seen: Some(now_unix()),
3568                    verified,
3569                    ed25519_pubkey: None,
3570                    role: "member".into(),
3571                },
3572            )?;
3573        }
3574        repo::set_member_verified(&self.db, room_id, fingerprint, verified)
3575    }
3576
3577    pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
3578        repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
3579    }
3580
3581    /// Phase B: is `fingerprint` an owner of `room_id`? Used by the TUI
3582    /// to gate `^K` / `^G` and the kick/grant member-picker actions.
3583    pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
3584        repo::list_room_owners(&self.db, room_id)
3585            .unwrap_or_default()
3586            .iter()
3587            .any(|fp| fp == fingerprint)
3588    }
3589
3590    pub fn we_are_owner(&self, room_id: &str) -> bool {
3591        self.is_owner(room_id, &self.identity.fingerprint().to_string())
3592    }
3593
3594    /// Phase B: list current owner fingerprints for `room_id` — used to
3595    /// render an owner badge in the member panel.
3596    pub fn room_owners(&self, room_id: &str) -> Vec<String> {
3597        repo::list_room_owners(&self.db, room_id).unwrap_or_default()
3598    }
3599
3600    /// huddle 0.7.6: true iff this session was started with a master
3601    /// passphrase. The TUI uses this to pick the Go Dark gate — passphrase
3602    /// if available (the natural strong secret the user already knows),
3603    /// else the typed `DELETE EVERYTHING` phrase since no-master-passphrase
3604    /// sessions have nothing else to compare against.
3605    pub fn has_master_passphrase(&self) -> bool {
3606        self.session_persist_key != [0u8; 32]
3607    }
3608
3609    /// Phase E: global toggle — when true, inbound dials from
3610    /// unverified fingerprints are auto-rejected without prompting.
3611    pub fn verified_only_inbound(&self) -> bool {
3612        repo::get_setting(&self.db, "verified_only_inbound")
3613            .unwrap_or(None)
3614            .map(|v| v == "1")
3615            .unwrap_or(false)
3616    }
3617
3618    pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
3619        repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
3620    }
3621
3622    /// huddle 0.7.8: persisted LAN-discovery toggle. When true, the
3623    /// next launch starts in `NetworkMode::Mdns` so the device joins
3624    /// LAN mDNS announcements. When false, the next launch starts in
3625    /// `NetworkMode::Direct` — invisible to LAN broadcast; only direct
3626    /// dial / invite link / configured relays can establish a peer.
3627    /// Default ON so existing users see no behavior change. Restart
3628    /// required to apply (libp2p's `Toggle<Mdns>` flip would require a
3629    /// behaviour rebuild; not worth the complexity for a rarely-touched
3630    /// setting).
3631    pub fn mdns_enabled(&self) -> bool {
3632        repo::get_setting(&self.db, "mdns_enabled")
3633            .unwrap_or(None)
3634            .map(|v| v == "1")
3635            .unwrap_or(true)
3636    }
3637
3638    pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
3639        repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
3640    }
3641
3642    /// huddle 0.7.8: persisted desktop-notification opt-out. The
3643    /// notifier itself is a local-only `osascript`/`notify-send`
3644    /// process call — toggling this OFF skips the call entirely so
3645    /// nothing reaches the OS notification daemon. Default ON to
3646    /// preserve current behavior.
3647    pub fn notifications_enabled(&self) -> bool {
3648        repo::get_setting(&self.db, "notifications_enabled")
3649            .unwrap_or(None)
3650            .map(|v| v == "1")
3651            .unwrap_or(true)
3652    }
3653
3654    pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
3655        repo::set_setting(
3656            &self.db,
3657            "notifications_enabled",
3658            if on { "1" } else { "0" },
3659        )
3660    }
3661
3662    /// huddle 0.7.8: stable 12-hex Safety Code derived from our Ed25519
3663    /// pubkey. Display-only; used as a quick visual fingerprint match in
3664    /// Profile / Account. SAS-via-emoji remains the actual verification
3665    /// primitive.
3666    pub fn safety_code(&self) -> String {
3667        crate::identity::safety_code(&self.identity.public_bytes())
3668    }
3669
3670    /// Phase E: per-room verified-only-join. When true, the host (and
3671    /// every honest existing member) drops MemberAnnounce from joiners
3672    /// who aren't globally SAS-verified, and the lowest-fp owner sends
3673    /// back a signed `JoinRefused` so the joiner sees an explanation.
3674    pub fn room_verified_only(&self, room_id: &str) -> bool {
3675        repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3676    }
3677
3678    pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
3679        repo::set_room_verified_only(&self.db, room_id, on)
3680    }
3681
3682    /// Phase H: first-launch onboarding flag.
3683    pub fn onboarding_seen(&self) -> bool {
3684        repo::is_onboarding_seen(&self.db).unwrap_or(true)
3685    }
3686
3687    pub fn mark_onboarding_seen(&self) -> Result<()> {
3688        repo::mark_onboarding_seen(&self.db)
3689    }
3690
3691    /// huddle 0.6: version string of huddle the user last finished
3692    /// onboarding for. Compared against `env!("CARGO_PKG_VERSION")` at
3693    /// startup so a version bump re-fires the "what's new" card.
3694    pub fn last_seen_onboarding_version(&self) -> Option<String> {
3695        repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
3696    }
3697
3698    pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
3699        repo::set_last_seen_onboarding_version(&self.db, version)
3700    }
3701
3702    /// huddle 0.6: opt-in flag for the crates.io update check.
3703    /// `None` ⇒ the user hasn't been asked yet.
3704    pub fn update_check_enabled(&self) -> Option<bool> {
3705        repo::get_update_check_enabled(&self.db).unwrap_or(None)
3706    }
3707
3708    pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
3709        repo::set_update_check_enabled(&self.db, enabled)
3710    }
3711
3712    /// huddle 0.6: cache anchor for the once-per-24h crates.io poll.
3713    /// Returns 0 if nothing has been recorded yet.
3714    pub fn last_update_check_at(&self) -> i64 {
3715        repo::get_setting(&self.db, "last_update_check_at")
3716            .ok()
3717            .flatten()
3718            .and_then(|s| s.parse().ok())
3719            .unwrap_or(0)
3720    }
3721
3722    pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
3723        repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
3724    }
3725
3726    /// huddle 0.6: the most recent `max_stable_version` we saw on
3727    /// crates.io. Persisted so a re-launch within the 24h window
3728    /// can render the banner without re-fetching.
3729    pub fn last_known_remote_version(&self) -> Option<String> {
3730        repo::get_setting(&self.db, "last_known_remote_version")
3731            .ok()
3732            .flatten()
3733    }
3734
3735    pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
3736        repo::set_setting(&self.db, "last_known_remote_version", v)
3737    }
3738
3739    /// Phase B: promote `target_fingerprint` to owner. Builds a signed
3740    /// `OwnerGrant`, broadcasts it, and applies it locally. Returns an
3741    /// error if we ourselves aren't an owner — only owners can grant.
3742    pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
3743        let our_fp = self.identity.fingerprint().to_string();
3744        if !self.is_owner(room_id, &our_fp) {
3745            return Err(HuddleError::Other(
3746                "only an owner can grant owner".into(),
3747            ));
3748        }
3749        let msg = RoomMessage::OwnerGrant {
3750            room_id: room_id.to_string(),
3751            target_fingerprint: target_fingerprint.to_string(),
3752        };
3753        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3754        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3755        self.network
3756            .publish_room_message(room_id.to_string(), bytes)
3757            .await;
3758        // Apply locally too — peers will converge on the next announce.
3759        repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
3760        Ok(())
3761    }
3762
3763    /// Phase B: kick `target_fingerprint` from `room_id`. Broadcasts a
3764    /// signed `BanMember`, records the ban locally, then immediately
3765    /// rotates the room key under a freshly-generated passphrase. Returns
3766    /// the new passphrase so the caller can show it to the owner for
3767    /// out-of-band sharing with remaining members.
3768    ///
3769    /// The rotation is the cryptographic enforcement: a banned peer can
3770    /// still subscribe to the gossipsub topic and see the ciphertext,
3771    /// but they can't unwrap the new session key without the new
3772    /// passphrase, so they can't decrypt anything sent after the kick.
3773    pub async fn kick_member(
3774        &self,
3775        room_id: &str,
3776        target_fingerprint: &str,
3777    ) -> Result<String> {
3778        let our_fp = self.identity.fingerprint().to_string();
3779        if !self.is_owner(room_id, &our_fp) {
3780            return Err(HuddleError::Other("only an owner can kick".into()));
3781        }
3782        if target_fingerprint == our_fp {
3783            return Err(HuddleError::Other("can't kick yourself".into()));
3784        }
3785        let info = self
3786            .active_rooms
3787            .lock()
3788            .unwrap()
3789            .get(room_id)
3790            .map(|r| r.info.clone())
3791            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3792        if !info.encrypted {
3793            // Without a key to rotate, a "kick" is purely advisory —
3794            // ban only. Honest clients drop their messages, but anyone
3795            // can still read the room. Honest in v1; documented.
3796            let msg = RoomMessage::BanMember {
3797                room_id: room_id.to_string(),
3798                target_fingerprint: target_fingerprint.to_string(),
3799            };
3800            let env = crate::crypto::sign_message(&self.identity, &msg)?;
3801            let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3802            self.network
3803                .publish_room_message(room_id.to_string(), bytes)
3804                .await;
3805            repo::add_room_ban(
3806                &self.db,
3807                room_id,
3808                target_fingerprint,
3809                &our_fp,
3810                &env.signature_b64,
3811                now_unix(),
3812            )?;
3813            self.evict_banned_member(room_id, target_fingerprint);
3814            return Ok(String::new());
3815        }
3816        // Encrypted room — full kick path.
3817        let new_passphrase = generate_join_passphrase();
3818        let msg = RoomMessage::BanMember {
3819            room_id: room_id.to_string(),
3820            target_fingerprint: target_fingerprint.to_string(),
3821        };
3822        let env = crate::crypto::sign_message(&self.identity, &msg)?;
3823        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3824        self.network
3825            .publish_room_message(room_id.to_string(), bytes)
3826            .await;
3827        repo::add_room_ban(
3828            &self.db,
3829            room_id,
3830            target_fingerprint,
3831            &our_fp,
3832            &env.signature_b64,
3833            now_unix(),
3834        )?;
3835        self.evict_banned_member(room_id, target_fingerprint);
3836        // Reuse the existing rotation flow so all the existing salt /
3837        // session / persistence logic stays in one place.
3838        self.rotate_room(room_id, &new_passphrase).await?;
3839        Ok(new_passphrase)
3840    }
3841
3842    /// Phase F: generate an 8-char alphanumeric join code for `room_id`,
3843    /// good for 10 minutes. Stored in memory only on the issuing owner's
3844    /// machine — a single use clears it. Caller is responsible for
3845    /// sharing the code OOB with the prospective joiner.
3846    ///
3847    /// Owner-only. Errors if `room_id` isn't active or we're not an owner.
3848    pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
3849        let our_fp = self.identity.fingerprint().to_string();
3850        if !self.is_owner(room_id, &our_fp) {
3851            return Err(HuddleError::Other(
3852                "only an owner can issue join codes".into(),
3853            ));
3854        }
3855        let code = generate_alphanumeric_code(8);
3856        let expires_at = now_unix() + 10 * 60;
3857        let mut rooms = self.active_rooms.lock().unwrap();
3858        let room = rooms
3859            .get_mut(room_id)
3860            .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
3861        // Prune expired entries while we're here so the list doesn't grow.
3862        let now = now_unix();
3863        room.issued_codes.retain(|(_, exp)| *exp > now);
3864        room.issued_codes.push((code.clone(), expires_at));
3865        Ok(code)
3866    }
3867
3868    /// Phase F: join `room_id` using a short-lived code instead of the
3869    /// passphrase. Generates an ephemeral X25519 keypair, broadcasts a
3870    /// signed `CodeJoinRequest`, and waits for the owner's
3871    /// `CodeJoinResponse`. The receive arm builds an `ActiveRoom`
3872    /// flagged read-only (no passphrase = can't share our outbound
3873    /// session key with others).
3874    pub async fn join_room_with_code(
3875        &self,
3876        room_id: &str,
3877        code: &str,
3878    ) -> Result<()> {
3879        // Resolve discovered metadata so we know name/encrypted/etc.
3880        let info = {
3881            let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
3882            match d {
3883                Some(d) => StoredRoom {
3884                    id: room_id.to_string(),
3885                    name: d.name,
3886                    creator_fingerprint: d.creator_fingerprint,
3887                    encrypted: d.encrypted,
3888                    passphrase_salt: None, // unused on code-join path
3889                    created_at: now_unix(),
3890                    last_active: Some(now_unix()),
3891                    // huddle 0.7: code-join is groups-only by design — DMs
3892                    // are 1-1 and don't use the code flow.
3893                    kind: d.kind,
3894                },
3895                None => {
3896                    return Err(HuddleError::Other(format!(
3897                        "room {room_id} not visible — wait for an announcement"
3898                    )))
3899                }
3900            }
3901        };
3902        if !info.encrypted {
3903            return Err(HuddleError::Other(
3904                "code-join only applies to encrypted rooms".into(),
3905            ));
3906        }
3907        let our_fp = self.identity.fingerprint().to_string();
3908        // Generate ephemeral X25519 keypair; remember the secret so the
3909        // CodeJoinResponse receive arm can complete ECDH on this peer.
3910        use x25519_dalek::{PublicKey, StaticSecret};
3911        let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
3912        let our_pub = PublicKey::from(&our_secret);
3913        // Stash the secret keyed by (room_id, our_fp); the response
3914        // handler removes the matching entry when a response targeted
3915        // at us arrives. The composite key means a second joiner can
3916        // be in flight in the same room without overwriting our state.
3917        let key = (room_id.to_string(), our_fp.clone());
3918        self.pending_code_secrets
3919            .lock()
3920            .unwrap()
3921            .insert(key.clone(), our_secret);
3922        // Code-join timeout: if no response in 30s, the entry will
3923        // still be in the map (the response handler removes it on
3924        // success). Surface a `CodeJoinTimedOut` to the TUI so the
3925        // user isn't stuck staring at an empty room expecting traffic.
3926        let map = self.pending_code_secrets.clone();
3927        let tx = self.app_event_tx.clone();
3928        let timeout_room = room_id.to_string();
3929        tokio::spawn(async move {
3930            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
3931            let still_pending = map.lock().unwrap().remove(&key).is_some();
3932            if still_pending {
3933                let _ = tx.send(AppEvent::CodeJoinTimedOut {
3934                    room_id: timeout_room,
3935                    reason: "no response from owner — code may be wrong or expired".into(),
3936                });
3937            }
3938        });
3939        // Persist the rooms row BEFORE constructing RoomCrypto, whose
3940        // `persist_outbound()` writes a `room_megolm_sessions` row with
3941        // a FK to `rooms(id)`. Without this, the FK fires and the
3942        // join aborts. The salt is left None for now — we don't have
3943        // the passphrase and the announcing peer's salt is cached in
3944        // ROOM_SALT_CACHE for whenever we get re-onboarded.
3945        repo::insert_room(&self.db, &info)?;
3946        // Create a placeholder ActiveRoom with no crypto yet; we'll
3947        // fill in the inbound session in the response handler.
3948        self.active_rooms.lock().unwrap().insert(
3949            room_id.to_string(),
3950            ActiveRoom {
3951                info: info.clone(),
3952                crypto: Some(RoomCrypto::new_for_room(
3953                    self.db.clone(),
3954                    room_id.to_string(),
3955                    our_fp.clone(),
3956                    self.session_persist_key,
3957                )?),
3958                passphrase_key: None,
3959                members: {
3960                    let mut s = HashSet::new();
3961                    s.insert(our_fp.clone());
3962                    s
3963                },
3964                typers: HashMap::new(),
3965                read_only: true,
3966                issued_codes: Vec::new(),
3967            },
3968        );
3969        self.network.subscribe_room(room_id.to_string()).await;
3970        // Broadcast the request.
3971        let req = RoomMessage::CodeJoinRequest {
3972            room_id: room_id.to_string(),
3973            joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3974            code: code.to_string(),
3975        };
3976        let env = crate::crypto::sign_message(&self.identity, &req)?;
3977        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
3978        self.network
3979            .publish_room_message(room_id.to_string(), bytes)
3980            .await;
3981        // Emit RoomJoined so the TUI opens the tab. Subsequent ability
3982        // to read messages depends on receiving the owner's response.
3983        let _ = self.app_event_tx.send(AppEvent::RoomJoined {
3984            room_id: room_id.to_string(),
3985        });
3986        Ok(())
3987    }
3988
3989    /// Phase G: start an SAS verification with `target_fingerprint` in
3990    /// `room_id`. Returns the tx_id so the caller can correlate
3991    /// subsequent events. The full flow is asynchronous — the partner
3992    /// must accept on their end, both compute the ECDH-derived SAS
3993    /// code, OOB-compare it, and each press Match.
3994    pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
3995        let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
3996        let tx_id = B64.encode(tx_id_bytes);
3997        let msg = RoomMessage::SasInit {
3998            tx_id: tx_id.clone(),
3999            ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4000            target_fingerprint: target_fingerprint.to_string(),
4001        };
4002        let env = crate::crypto::sign_message(&self.identity, &msg)?;
4003        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4004        self.sas_flows.lock().unwrap().insert(
4005            tx_id.clone(),
4006            SasFlow {
4007                room_id: room_id.to_string(),
4008                partner_fingerprint: target_fingerprint.to_string(),
4009                our_secret,
4010                sas_code: None,
4011                our_confirmed: false,
4012                their_confirmed: false,
4013                finalized: false,
4014            },
4015        );
4016        self.network
4017            .publish_room_message(room_id.to_string(), bytes)
4018            .await;
4019        Ok(tx_id)
4020    }
4021
4022    /// Phase G: user pressed Match on the SAS code modal — broadcast our
4023    /// signed `SasConfirm{matched: true}`. If the partner has already
4024    /// matched, this completes verification on both sides.
4025    pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
4026        let (room_id, partner_fp, both_done) = {
4027            let mut flows = self.sas_flows.lock().unwrap();
4028            let flow = flows
4029                .get_mut(tx_id)
4030                .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
4031            flow.our_confirmed = true;
4032            // huddle 0.7.11: latch finalize so the inbound SasConfirm
4033            // handler won't fire `finish_sas` a second time. See
4034            // SasConfirm arm for the symmetric guard.
4035            let do_finish = flow.our_confirmed && flow.their_confirmed && !flow.finalized;
4036            if do_finish {
4037                flow.finalized = true;
4038            }
4039            (
4040                flow.room_id.clone(),
4041                flow.partner_fingerprint.clone(),
4042                do_finish,
4043            )
4044        };
4045        let msg = RoomMessage::SasConfirm {
4046            tx_id: tx_id.to_string(),
4047            matched: true,
4048        };
4049        let env = crate::crypto::sign_message(&self.identity, &msg)?;
4050        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4051        self.network
4052            .publish_room_message(room_id.clone(), bytes)
4053            .await;
4054        if both_done {
4055            self.finish_sas(tx_id, &room_id, &partner_fp).await?;
4056        }
4057        Ok(())
4058    }
4059
4060    /// Phase G: cancel an in-flight SAS — drop our local state. Doesn't
4061    /// broadcast a "matched=false" notice in v1 (partner's flow stays
4062    /// dangling; they can cancel their side too). Quiet teardown.
4063    pub fn sas_cancel(&self, tx_id: &str) {
4064        self.sas_flows.lock().unwrap().remove(tx_id);
4065    }
4066
4067    /// Phase G internal: both sides have confirmed — flip the partner's
4068    /// fingerprint to verified (per-room AND global) and clean up.
4069    async fn finish_sas(
4070        &self,
4071        tx_id: &str,
4072        room_id: &str,
4073        partner_fingerprint: &str,
4074    ) -> Result<()> {
4075        repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
4076        repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
4077        self.sas_flows.lock().unwrap().remove(tx_id);
4078        let _ = self.app_event_tx.send(AppEvent::SasVerified {
4079            room_id: room_id.to_string(),
4080            partner_fingerprint: partner_fingerprint.to_string(),
4081        });
4082        Ok(())
4083    }
4084
4085    /// Phase B internal: drop a banned member's in-memory presence in a
4086    /// room. Persistent ban already went to `room_bans`. Called from
4087    /// `kick_member` (locally banning ourselves) and from the
4088    /// `RoomMessage::BanMember` receive arm (peer-initiated ban).
4089    fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
4090        if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
4091            room.members.remove(fingerprint);
4092        }
4093        let _ = self.app_event_tx.send(AppEvent::MemberLeft {
4094            room_id: room_id.to_string(),
4095            fingerprint: fingerprint.to_string(),
4096        });
4097    }
4098
4099    pub fn display_name(&self) -> Option<String> {
4100        repo::get_display_name(&self.db).unwrap_or(None)
4101    }
4102
4103    pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
4104        repo::set_display_name(&self.db, name)
4105    }
4106
4107    /// huddle 0.5: set the local user's self-declared username (or clear
4108    /// it with None) and broadcast a signed `ProfileUpdate` to every
4109    /// joined room. Receivers cache the latest per-fingerprint username
4110    /// in `peer_profiles`; unsigned envelopes are dropped at the receive
4111    /// arm so the username can't be spoofed.
4112    pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
4113        repo::set_display_name(&self.db, name)?;
4114        let msg = RoomMessage::ProfileUpdate {
4115            sender_fingerprint: self.identity.fingerprint().to_string(),
4116            username: name.map(|s| s.to_string()),
4117            updated_at: now_unix_ms(),
4118        };
4119        let env = crate::crypto::sign_message(&self.identity, &msg)?;
4120        let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4121        let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
4122        for room_id in rooms {
4123            self.network
4124                .publish_room_message(room_id, bytes.clone())
4125                .await;
4126        }
4127        Ok(())
4128    }
4129
4130    /// huddle 0.5: cached username for a peer (any peer we've ever
4131    /// received a signed `ProfileUpdate` from), or None if unknown or
4132    /// the peer cleared their username. Callers render `[anonymous]` on
4133    /// None.
4134    pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
4135        repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
4136    }
4137
4138    /// Look up the display name we've seen for a peer. Forwards to
4139    /// `lookup_username` (the new signed-source-of-truth) so existing
4140    /// call sites get the authenticated value without churn.
4141    pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
4142        self.lookup_username(fingerprint)
4143    }
4144
4145    pub fn is_room_muted(&self, room_id: &str) -> bool {
4146        repo::is_room_muted(&self.db, room_id).unwrap_or(false)
4147    }
4148
4149    /// Phase B: list the fingerprints currently banned from a room
4150    /// (newest first). Backs the `^B` in-room view; intended for
4151    /// owners but the read itself is harmless and we let callers
4152    /// gate via `we_are_owner` if they want owner-only display.
4153    pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
4154        repo::list_room_bans(&self.db, room_id).unwrap_or_default()
4155    }
4156
4157    /// Phase A: list every globally-blocked peer (one fingerprint per
4158    /// row). Surfaced in the Settings modal alongside a clear-all
4159    /// action that calls `unblock_peer` in a loop.
4160    /// huddle 0.7: every globally SAS-verified peer. Surfaced in the
4161    /// People pane's "Verified" sub-list.
4162    pub fn list_verified_peers(&self) -> Vec<String> {
4163        repo::list_verified_peers(&self.db).unwrap_or_default()
4164    }
4165
4166    pub fn list_blocked_peers(&self) -> Vec<String> {
4167        repo::list_blocked_peers(&self.db).unwrap_or_default()
4168    }
4169
4170    /// Phase A: remove `fingerprint` from the persistent blocklist. The
4171    /// peer will no longer be auto-rejected on connection; they fall
4172    /// back to the regular inbound-dial accept/reject prompt.
4173    pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
4174        repo::unblock_peer(&self.db, fingerprint)
4175    }
4176
4177    /// huddle 0.7: add `fingerprint` to the persistent blocklist. Used
4178    /// by the People pane's per-row "block" action. Subsequent inbound
4179    /// dials from this fingerprint are auto-rejected without prompting.
4180    pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
4181        repo::block_peer(&self.db, fingerprint, now_unix())
4182    }
4183
4184    /// Phase F: rooms entered via a join code don't have the passphrase
4185    /// in memory, so the joining peer can't wrap their own outbound
4186    /// session key for newer members — they can read and send, they
4187    /// just can't onboard others. The TUI renders a `(read-only)`
4188    /// badge in the room tab so the user understands.
4189    pub fn is_room_read_only(&self, room_id: &str) -> bool {
4190        self.active_rooms
4191            .lock()
4192            .unwrap()
4193            .get(room_id)
4194            .map(|r| r.read_only)
4195            .unwrap_or(false)
4196    }
4197
4198    pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
4199        repo::set_room_muted(&self.db, room_id, muted)
4200    }
4201
4202    /// Broadcast a "I'm typing" pulse to the given room. Caller is
4203    /// responsible for debouncing (don't fire more than every ~500ms).
4204    pub async fn broadcast_typing(&self, room_id: &str) {
4205        if !self.active_rooms.lock().unwrap().contains_key(room_id) {
4206            return;
4207        }
4208        let msg = RoomMessage::Typing {
4209            sender_fingerprint: self.identity.fingerprint().to_string(),
4210        };
4211        if let Ok(bytes) = encode_wire(&msg) {
4212            self.network
4213                .publish_room_message(room_id.to_string(), bytes)
4214                .await;
4215        }
4216    }
4217
4218    /// Returns the fingerprints of peers currently typing in `room_id`,
4219    /// pruning entries past their TTL.
4220    pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
4221        let now = now_unix();
4222        let mut rooms = self.active_rooms.lock().unwrap();
4223        let room = match rooms.get_mut(room_id) {
4224            Some(r) => r,
4225            None => return Vec::new(),
4226        };
4227        room.typers.retain(|_, exp| *exp > now);
4228        let mut v: Vec<String> = room.typers.keys().cloned().collect();
4229        v.sort();
4230        v
4231    }
4232
4233    // -------------------------------------------------------------------
4234    // Room key rotation
4235    // -------------------------------------------------------------------
4236
4237    /// Rotate this room's outbound Megolm session under a fresh
4238    /// passphrase. Broadcasts `RotateRoomKey` (so other members know to
4239    /// expect a new passphrase) and a fresh `MemberAnnounce` with the
4240    /// new wrapped session key. Old inbound sessions stay in storage
4241    /// for decrypting historic messages.
4242    pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
4243        if new_passphrase.is_empty() {
4244            return Err(HuddleError::Other("new passphrase is empty".into()));
4245        }
4246        let new_salt = passphrase::random_salt();
4247        let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
4248
4249        let info = {
4250            let mut rooms = self.active_rooms.lock().unwrap();
4251            let room = rooms
4252                .get_mut(room_id)
4253                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4254            if !room.info.encrypted {
4255                return Err(HuddleError::Other(
4256                    "rotation only applies to encrypted rooms".into(),
4257                ));
4258            }
4259            // Generate a fresh outbound Megolm session for this member.
4260            let new_crypto = RoomCrypto::new_for_room(
4261                self.db.clone(),
4262                room_id.to_string(),
4263                self.identity.fingerprint().to_string(),
4264                self.session_persist_key,
4265            )?;
4266            room.crypto = Some(new_crypto);
4267            room.passphrase_key = Some(new_key);
4268            room.info.passphrase_salt = Some(new_salt.to_vec());
4269            room.info.clone()
4270        };
4271
4272        // Broadcast before persisting: peers learn about the rotation even
4273        // if we crash before the DB write lands, and our own restore path
4274        // can recover from the persisted Megolm session plus the announced
4275        // salt. Persisting first would risk a DB row that's ahead of what
4276        // any peer knows.
4277        let rot = RoomMessage::RotateRoomKey {
4278            rotator_fingerprint: self.identity.fingerprint().to_string(),
4279            new_salt: new_salt.to_vec(),
4280        };
4281        // Signed: rotations are self-attested, so peers can prove the
4282        // claimed `rotator_fingerprint` really came from that identity.
4283        // An unsigned rotation is rejected on the receive side.
4284        if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
4285            if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4286                self.network
4287                    .publish_room_message(room_id.to_string(), bytes)
4288                    .await;
4289            }
4290        }
4291        // Re-announce ourselves with the new wrapped session key.
4292        if let Err(e) = self.broadcast_member_announce(room_id).await {
4293            warn!(%e, "rotate: broadcast announce failed");
4294        }
4295
4296        // Now persist the new salt on the stored row.
4297        repo::insert_room(&self.db, &info)?;
4298        Ok(())
4299    }
4300
4301    /// Used by the TUI when another member rotates a room we're in.
4302    /// Derives the new key, updates our local state, and re-announces
4303    /// so the rotator can share their fresh outbound session with us.
4304    pub async fn accept_rotation(
4305        &self,
4306        room_id: &str,
4307        new_salt: &[u8],
4308        new_passphrase: &str,
4309    ) -> Result<()> {
4310        let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
4311        let info = {
4312            let mut rooms = self.active_rooms.lock().unwrap();
4313            let room = rooms
4314                .get_mut(room_id)
4315                .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4316            room.passphrase_key = Some(new_key);
4317            room.info.passphrase_salt = Some(new_salt.to_vec());
4318            room.info.clone()
4319        };
4320        // Ask the rotator (and anyone) to re-share their session key
4321        // before persisting, so a crash before the DB write still leaves
4322        // peers aware we've moved to the new salt.
4323        let req = RoomMessage::SessionKeyRequest {
4324            requester_fingerprint: self.identity.fingerprint().to_string(),
4325        };
4326        if let Ok(bytes) = encode_wire(&req) {
4327            self.network
4328                .publish_room_message(room_id.to_string(), bytes)
4329                .await;
4330        }
4331        repo::insert_room(&self.db, &info)?;
4332        Ok(())
4333    }
4334
4335    // -------------------------------------------------------------------
4336    // File transfer — internal handlers
4337    // -------------------------------------------------------------------
4338
4339    #[allow(clippy::too_many_arguments)]
4340    fn handle_file_offer(
4341        &self,
4342        room_id: &str,
4343        sender_fingerprint: String,
4344        file_id: String,
4345        name: String,
4346        size_bytes: u64,
4347        mime: Option<String>,
4348        _chunk_count: u32,
4349        encrypted_meta: Option<EncryptedFileMeta>,
4350    ) {
4351        let encrypted = encrypted_meta.is_some();
4352        let attachment = StoredAttachment {
4353            id: 0,
4354            room_id: room_id.to_string(),
4355            message_id: None,
4356            sender_fingerprint: sender_fingerprint.clone(),
4357            file_id: file_id.clone(),
4358            name: name.clone(),
4359            mime,
4360            size_bytes: size_bytes as i64,
4361            status: AttachmentStatus::Offered,
4362            cache_path: None,
4363            saved_path: None,
4364            error: None,
4365            encrypted,
4366            wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
4367            nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
4368            megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
4369            content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
4370            created_at: now_unix(),
4371        };
4372        if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
4373            warn!(%e, "upsert attachment");
4374            return;
4375        }
4376        // If chunks started arriving before this offer, the transfer's
4377        // size denominator was a guess — correct it with the real size.
4378        self.file_manager.set_expected_size(&file_id, size_bytes);
4379        let _ = self.app_event_tx.send(AppEvent::FileOffered {
4380            room_id: room_id.to_string(),
4381            file_id,
4382            name,
4383            size_bytes,
4384            sender_fingerprint,
4385        });
4386    }
4387
4388    fn handle_file_chunk(
4389        &self,
4390        room_id: &str,
4391        _sender_fingerprint: String,
4392        file_id: String,
4393        chunk_index: u32,
4394        total_chunks: u32,
4395        data_b64: String,
4396    ) {
4397        let data = match B64.decode(&data_b64) {
4398            Ok(d) => d,
4399            Err(e) => {
4400                warn!(%e, "bad chunk base64");
4401                return;
4402            }
4403        };
4404        // Pull the announced size + lifecycle state from our stored offer.
4405        // A terminal-state row means the user cancelled or the transfer
4406        // already failed — late chunks must not resurrect it.
4407        let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
4408            Ok(Some(a)) => {
4409                if matches!(
4410                    a.status,
4411                    AttachmentStatus::Cancelled | AttachmentStatus::Failed
4412                ) {
4413                    return;
4414                }
4415                a.size_bytes as u64
4416            }
4417            Ok(None) => crate::files::MAX_FILE_SIZE,
4418            Err(e) => {
4419                warn!(%e, "get attachment for chunk");
4420                crate::files::MAX_FILE_SIZE
4421            }
4422        };
4423
4424        let result = self.file_manager.accept_chunk(
4425            &file_id,
4426            chunk_index,
4427            total_chunks,
4428            data,
4429            expected_size,
4430        );
4431        match result {
4432            Ok(None) => {
4433                // Move offered → downloading on first chunk.
4434                let _ = repo::update_attachment_status(
4435                    &self.db,
4436                    room_id,
4437                    &file_id,
4438                    AttachmentStatus::Downloading,
4439                    None,
4440                );
4441                // Best-effort progress event — we know we've processed
4442                // (chunk_index+1)/total_chunks chunks.
4443                let bytes_so_far = self
4444                    .file_manager
4445                    .progress(&file_id)
4446                    .map(|(b, _)| b)
4447                    .unwrap_or(0);
4448                let _ = self.app_event_tx.send(AppEvent::FileProgress {
4449                    file_id: file_id.clone(),
4450                    bytes_received: bytes_so_far,
4451                    total_bytes: expected_size,
4452                });
4453            }
4454            Ok(Some(completed)) => {
4455                let _ = repo::update_attachment_paths(
4456                    &self.db,
4457                    room_id,
4458                    &file_id,
4459                    Some(&completed.cache_path.to_string_lossy()),
4460                    None,
4461                );
4462                let _ = repo::update_attachment_status(
4463                    &self.db,
4464                    room_id,
4465                    &file_id,
4466                    AttachmentStatus::Ready,
4467                    None,
4468                );
4469                let _ = self.app_event_tx.send(AppEvent::FileReady {
4470                    file_id: file_id.clone(),
4471                });
4472            }
4473            Err(e) => {
4474                let msg = e.to_string();
4475                warn!(%msg, "chunk processing failed");
4476                let _ = repo::update_attachment_status(
4477                    &self.db,
4478                    room_id,
4479                    &file_id,
4480                    AttachmentStatus::Failed,
4481                    Some(&msg),
4482                );
4483                let _ = self.app_event_tx.send(AppEvent::FileFailed {
4484                    file_id: file_id.clone(),
4485                    reason: msg,
4486                });
4487            }
4488        }
4489    }
4490
4491    /// Emit MentionReceived if `body` contains either our full
4492    /// fingerprint or our `HD-XXXX-XXXX` 8-hex-char prefix.
4493    ///
4494    /// huddle 0.7.11: pre-0.7.11 the short-form match used only the
4495    /// first 4-hex group (~65 K possibilities), so unrelated peers
4496    /// sharing a prefix triggered false mentions — and a hostile peer
4497    /// could weaponize a 4-hex literal in their message body to spam
4498    /// the victim's terminal bell, bypassing per-room mute. Bumping to
4499    /// the first 8 hex chars makes the search space 16^8 ≈ 4 billion
4500    /// and effectively eliminates collisions while still being short
4501    /// enough to type as a mention ("hey HD-a3b1c2d4 …").
4502    fn maybe_emit_mention(&self, room_id: &str, body: &str) {
4503        let full = self.identity.fingerprint().to_lowercase();
4504        // First 8 hex chars (two dash-separated groups joined), e.g.
4505        // "a3b1c2d4" of "a3b1-c2d4-…".
4506        let short: String = full.chars().filter(|c| c.is_ascii_hexdigit()).take(8).collect();
4507        let lower = body.to_lowercase();
4508        let hit = lower.contains(full.as_str())
4509            || lower
4510                .split(|c: char| !c.is_ascii_hexdigit())
4511                .any(|tok| tok == short);
4512        if hit {
4513            let _ = self.app_event_tx.send(AppEvent::MentionReceived {
4514                room_id: room_id.to_string(),
4515                body: body.to_string(),
4516            });
4517        }
4518    }
4519
4520    fn decrypt_attachment(
4521        &self,
4522        room_id: &str,
4523        sender_fingerprint: &str,
4524        ciphertext: &[u8],
4525        meta: &EncryptedFileMeta,
4526    ) -> Result<Vec<u8>> {
4527        let mut rooms = self.active_rooms.lock().unwrap();
4528        let room = rooms
4529            .get_mut(room_id)
4530            .ok_or_else(|| HuddleError::Other("not in room".into()))?;
4531        let crypto = room
4532            .crypto
4533            .as_mut()
4534            .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4535        file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
4536    }
4537
4538    /// huddle 0.5: irreversibly delete this account. Verifies the
4539    /// master passphrase, best-effort `MemberLeave`s every joined room
4540    /// (capped at 2 s so a single unresponsive transport can't hang
4541    /// the wipe), shuts down the network, then deletes the database,
4542    /// keychain salt, log, and config files from `config::data_dir()`.
4543    /// Emits `AppEvent::WentDark` on success so the TUI can show a
4544    /// goodbye modal and exit.
4545    ///
4546    /// In `--no-master-passphrase` mode (`self.session_persist_key`
4547    /// is all-zero), the passphrase check is skipped — the typed
4548    /// `DELETE EVERYTHING` confirmation in the TUI is the only gate.
4549    pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
4550        let no_master = self.session_persist_key == [0u8; 32];
4551        if !no_master {
4552            let salt = storage::keychain::load_or_create_salt()?;
4553            let candidate_master =
4554                storage::keychain::derive_master_key(master_passphrase, &salt)?;
4555            let candidate_subkey =
4556                storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
4557            if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
4558                return Err(HuddleError::Other(
4559                    "incorrect master passphrase".into(),
4560                ));
4561            }
4562        }
4563
4564        let room_ids: Vec<String> = self
4565            .active_rooms
4566            .lock()
4567            .unwrap()
4568            .keys()
4569            .cloned()
4570            .collect();
4571        let _ = tokio::time::timeout(Duration::from_secs(2), async {
4572            for room_id in &room_ids {
4573                if let Err(e) = self.leave_room(room_id).await {
4574                    warn!(%room_id, %e, "go_dark: leave_room failed");
4575                }
4576            }
4577        })
4578        .await;
4579
4580        self.network.shutdown().await;
4581        tokio::time::sleep(Duration::from_millis(300)).await;
4582
4583        let data_dir = config::data_dir();
4584        let candidates = [
4585            "huddle.db",
4586            "huddle.db-shm",
4587            "huddle.db-wal",
4588            "keychain.salt",
4589            "huddle.log",
4590            "config.toml",
4591        ];
4592        for name in &candidates {
4593            let path = data_dir.join(name);
4594            wipe_file(&path);
4595        }
4596        if let Ok(read) = std::fs::read_dir(&data_dir) {
4597            for entry in read.flatten() {
4598                if let Some(name) = entry.file_name().to_str() {
4599                    if name.starts_with("huddle.log.") {
4600                        wipe_file(&entry.path());
4601                    }
4602                }
4603            }
4604        }
4605        // huddle 0.5.1: wipe the attachment cache directory. Each file
4606        // inside is best-effort zeroed first, then the directory
4607        // itself is removed.
4608        let files_dir = data_dir.join("files");
4609        if let Ok(read) = std::fs::read_dir(&files_dir) {
4610            for entry in read.flatten() {
4611                let path = entry.path();
4612                if path.is_file() {
4613                    wipe_file(&path);
4614                } else if path.is_dir() {
4615                    // Two-level nesting (room_id subdirs) — sweep their
4616                    // contents too.
4617                    if let Ok(inner) = std::fs::read_dir(&path) {
4618                        for inner_entry in inner.flatten() {
4619                            if inner_entry.path().is_file() {
4620                                wipe_file(&inner_entry.path());
4621                            }
4622                        }
4623                    }
4624                    let _ = std::fs::remove_dir(&path);
4625                }
4626            }
4627        }
4628        let _ = std::fs::remove_dir(&files_dir);
4629        let _ = std::fs::remove_dir(&data_dir);
4630
4631        let _ = self.app_event_tx.send(AppEvent::WentDark);
4632        Ok(())
4633    }
4634}
4635
4636/// huddle 0.5.1: parse `input` as a huddle ID — either `HD-`-prefixed
4637/// or a bare 24-char hex run with or without dashes — and return it in
4638/// the canonical lowercase-dashed form `xxxx-xxxx-...-xxxx` that
4639/// matches `identity::compute_fingerprint`'s output. Returns None for
4640/// anything that isn't a syntactic ID (the caller falls back to
4641/// username lookup).
4642pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
4643    let s = input
4644        .trim()
4645        .trim_start_matches("HD-")
4646        .trim_start_matches("hd-")
4647        .to_string();
4648    let hex_only: String = s.chars().filter(|c| *c != '-').collect();
4649    if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
4650        return None;
4651    }
4652    let lower = hex_only.to_ascii_lowercase();
4653    let chunks: Vec<String> = lower
4654        .as_bytes()
4655        .chunks(4)
4656        .map(|c| std::str::from_utf8(c).unwrap().to_string())
4657        .collect();
4658    Some(chunks.join("-"))
4659}
4660
4661/// huddle 0.5.2: rank a multiaddr by transport preference. Lower =
4662/// better. Used to sort candidate addresses for the parallel dialer so
4663/// LAN connections get a head-start over relay-hopped ones when wall-
4664/// times are close. The numeric values are arbitrary; only the
4665/// ordering matters.
4666fn address_preference(addr: &str) -> u8 {
4667    if addr.contains("/p2p-circuit") {
4668        return 9; // relay-hopped — bottom of the list
4669    }
4670    if let Some(rest) = addr.strip_prefix("/ip4/") {
4671        if let Some(ip_str) = rest.split('/').next() {
4672            if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
4673                if ip.is_loopback() {
4674                    return 1; // useful for tests
4675                }
4676                if is_rfc1918(&ip) || ip.is_link_local() {
4677                    return 0; // LAN — wins ties
4678                }
4679                return 3; // public ipv4
4680            }
4681        }
4682        return 3;
4683    }
4684    if addr.starts_with("/ip6/") {
4685        return 4;
4686    }
4687    if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
4688        return 5;
4689    }
4690    7
4691}
4692
4693/// True for IPv4 addresses in private (RFC 1918) ranges — 10/8,
4694/// 172.16/12, 192.168/16. Used by `address_preference` to score LAN
4695/// dials ahead of public-IP and relay-hopped ones.
4696fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
4697    let octets = ip.octets();
4698    octets[0] == 10
4699        || (octets[0] == 172 && (16..=31).contains(&octets[1]))
4700        || (octets[0] == 192 && octets[1] == 168)
4701}
4702
4703/// Short label for an HD ID, used only in error messages — strips the
4704/// fingerprint down to its first four hex chars with the brand prefix
4705/// so the message reads naturally.
4706fn short_fp_for_msg(fingerprint: &str) -> String {
4707    let head: String = fingerprint
4708        .chars()
4709        .filter(|c| *c != '-')
4710        .take(4)
4711        .collect::<String>()
4712        .to_ascii_uppercase();
4713    format!("HD-{}…", head)
4714}
4715
4716/// Constant-time 32-byte equality. Used by `go_dark` to compare a
4717/// re-derived HKDF subkey to the in-memory `session_persist_key`
4718/// without leaking timing information about which byte differed.
4719fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
4720    let mut diff = 0u8;
4721    for i in 0..32 {
4722        diff |= a[i] ^ b[i];
4723    }
4724    diff == 0
4725}
4726
4727/// Best-effort file wipe: overwrite with zeros, then delete. Missing /
4728/// permission-denied files are logged and skipped. Called from
4729/// `go_dark` only — not a general-purpose util.
4730fn wipe_file(path: &Path) {
4731    use std::io::Write;
4732    // huddle 0.7.11: write zeros in a 64 KiB scratch buffer instead of
4733    // allocating a vec the full file size. The original implementation
4734    // OOM'd `go_dark` mid-wipe whenever a user had downloaded a
4735    // multi-GB attachment — the panic aborted before DB / config wipe,
4736    // leaving a half-wiped data dir.
4737    const SCRATCH: usize = 64 * 1024;
4738    if let Ok(meta) = std::fs::metadata(path) {
4739        if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
4740            let zeros = [0u8; SCRATCH];
4741            let mut remaining = meta.len();
4742            while remaining > 0 {
4743                let n = remaining.min(SCRATCH as u64) as usize;
4744                if f.write_all(&zeros[..n]).is_err() {
4745                    break;
4746                }
4747                remaining -= n as u64;
4748            }
4749            let _ = f.sync_all();
4750        }
4751    }
4752    if let Err(e) = std::fs::remove_file(path) {
4753        if e.kind() != std::io::ErrorKind::NotFound {
4754            warn!(?path, %e, "wipe_file: remove failed");
4755        }
4756    }
4757}
4758
4759/// Use the platform's default opener on `path`.
4760fn open_with_system(path: &str) -> Result<()> {
4761    #[cfg(target_os = "macos")]
4762    let cmd = "open";
4763    #[cfg(target_os = "linux")]
4764    let cmd = "xdg-open";
4765    #[cfg(target_os = "windows")]
4766    let cmd = "cmd";
4767    #[cfg(target_os = "windows")]
4768    let args = vec!["/C", "start", "", path];
4769    #[cfg(not(target_os = "windows"))]
4770    let args = vec![path];
4771
4772    std::process::Command::new(cmd)
4773        .args(args)
4774        .spawn()
4775        .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
4776    Ok(())
4777}
4778
4779// Module-level salt cache: room_id -> salt. Populated when we receive
4780// announcements; queried by join_room.
4781static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
4782    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
4783
4784/// Public accessor for the Argon2id salt length used when deriving room
4785/// passphrase keys. Exists so downstream tooling (status pages, debug
4786/// CLIs, integration tests) can confirm the expected size without
4787/// re-importing the constant from `crypto::passphrase`.
4788pub fn salt_len() -> usize {
4789    SALT_LEN
4790}
4791
4792fn now_unix() -> i64 {
4793    SystemTime::now()
4794        .duration_since(UNIX_EPOCH)
4795        .unwrap()
4796        .as_secs() as i64
4797}
4798
4799fn now_unix_ms() -> i64 {
4800    SystemTime::now()
4801        .duration_since(UNIX_EPOCH)
4802        .unwrap()
4803        .as_millis() as i64
4804}
4805
4806/// Phase B: generate a fresh 24-char base64-ish passphrase for the
4807/// rotation that follows a kick. Sourced from `OsRng` directly so the
4808/// kicker doesn't have to think up a strong one on the spot. Returned
4809/// to the owner via the kick-result modal for OOB sharing with the
4810/// remaining members.
4811fn generate_join_passphrase() -> String {
4812    use rand::RngCore;
4813    let mut bytes = [0u8; 16];
4814    rand::thread_rng().fill_bytes(&mut bytes);
4815    // Use URL-safe-no-pad so the user can read aloud / paste without
4816    // worrying about `=` padding or `+` getting URL-escaped.
4817    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
4818}
4819
4820/// Phase F: short human-readable join code. 8 chars from a 31-symbol
4821/// alphabet (no easily-confused chars like 0/O/I/1/L) ≈ 39.6 bits —
4822/// plenty for a 10-minute online gate since the owner's client checks
4823/// exact-match (not brute-force-able offline).
4824///
4825/// huddle 0.7.11: comment said "32-symbol" but the literal contains 31
4826/// bytes (A-Z minus I/L/O = 23, plus 2-9 = 8, total 31). Doc updated
4827/// to match.
4828fn generate_alphanumeric_code(len: usize) -> String {
4829    use rand::Rng;
4830    const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4831    let mut rng = rand::thread_rng();
4832    let mut out = String::with_capacity(len + 1);
4833    for i in 0..len {
4834        if i == 4 && len == 8 {
4835            out.push('-'); // pretty: XXXX-XXXX
4836        }
4837        let idx = rng.gen_range(0..ALPHABET.len());
4838        out.push(ALPHABET[idx] as char);
4839    }
4840    out
4841}
4842
4843#[cfg(test)]
4844mod parser_tests {
4845    use super::parse_dial_address;
4846
4847    #[test]
4848    fn parses_ipv4_port() {
4849        let m = parse_dial_address("10.3.72.53:9027").unwrap();
4850        assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
4851    }
4852
4853    #[test]
4854    fn parses_bracketed_ipv6() {
4855        let m = parse_dial_address("[::1]:9027").unwrap();
4856        assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
4857    }
4858
4859    #[test]
4860    fn rejects_unbracketed_ipv6() {
4861        let err = parse_dial_address("fe80::1:9027").unwrap_err();
4862        assert!(err.to_string().contains("brackets"));
4863    }
4864
4865    #[test]
4866    fn passes_through_raw_multiaddr() {
4867        let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
4868        assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
4869    }
4870
4871    #[test]
4872    fn empty_address_is_error() {
4873        assert!(parse_dial_address("   ").is_err());
4874    }
4875
4876    #[test]
4877    fn rejects_bad_port() {
4878        assert!(parse_dial_address("1.2.3.4:notaport").is_err());
4879    }
4880}
4881
4882#[cfg(test)]
4883mod transport_preference_tests {
4884    use super::{address_preference, normalize_to_fingerprint};
4885
4886    #[test]
4887    fn lan_beats_public_beats_circuit() {
4888        let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
4889        let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
4890        let circuit = address_preference(
4891            "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
4892        );
4893        assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
4894        assert!(
4895            pub_v4 < circuit,
4896            "public {} should beat circuit {}",
4897            pub_v4,
4898            circuit
4899        );
4900    }
4901
4902    #[test]
4903    fn all_rfc1918_ranges_are_lan() {
4904        assert_eq!(
4905            address_preference("/ip4/10.0.0.1/tcp/9027"),
4906            address_preference("/ip4/192.168.0.1/tcp/9027"),
4907        );
4908        assert_eq!(
4909            address_preference("/ip4/172.16.0.1/tcp/9027"),
4910            address_preference("/ip4/192.168.0.1/tcp/9027"),
4911        );
4912        // 172.32.x.x is OUTSIDE the 172.16-31 RFC1918 slice.
4913        assert!(
4914            address_preference("/ip4/172.32.0.1/tcp/9027")
4915                > address_preference("/ip4/172.16.0.1/tcp/9027")
4916        );
4917    }
4918
4919    #[test]
4920    fn normalize_id_accepts_branded_and_raw() {
4921        let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4922        assert_eq!(
4923            normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
4924            Some(canon)
4925        );
4926        assert_eq!(
4927            normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
4928            Some(canon)
4929        );
4930        assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
4931        assert!(normalize_to_fingerprint("alice").is_none());
4932        assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
4933    }
4934}
4935
4936#[cfg(test)]
4937mod canonical_dm_room_id_tests {
4938    use super::canonical_dm_room_id;
4939
4940    #[test]
4941    fn dm_room_id_is_commutative() {
4942        // The single load-bearing property: both peers, no matter who
4943        // calls `start_direct` first, derive identical IDs.
4944        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4945        let b = "1111-2222-3333-4444-5555-6666";
4946        assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
4947    }
4948
4949    #[test]
4950    fn dm_room_id_differs_per_pair() {
4951        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4952        let b = "1111-2222-3333-4444-5555-6666";
4953        let c = "9999-8888-7777-6666-5555-4444";
4954        assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
4955        assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
4956    }
4957
4958    #[test]
4959    fn dm_room_id_is_stable() {
4960        // Deterministic by construction; this guards against
4961        // accidentally mixing in a timestamp or nonce in a future
4962        // refactor — that would break idempotency across peers.
4963        let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
4964        let b = "1111-2222-3333-4444-5555-6666";
4965        let id1 = canonical_dm_room_id(a, b);
4966        let id2 = canonical_dm_room_id(a, b);
4967        assert_eq!(id1, id2);
4968        // Same length as `derive_room_id` output (32 hex chars / 16
4969        // bytes) so DM IDs are indistinguishable from group IDs at the
4970        // topic-name layer.
4971        assert_eq!(id1.len(), 32);
4972    }
4973}