Skip to main content

huddle_core/app/
mod.rs

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