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