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