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