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