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