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