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