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