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