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 /// huddle 1.2.1: ask the relay to mint a short-lived **connect code** bound
2111 /// to our identity, so a peer can add/DM us by typing the code instead of
2112 /// our full HD-ID. The code (and its expiry) arrive asynchronously as
2113 /// `AppEvent::ConnectCodeCreated`. Errors immediately if the relay isn't
2114 /// connected (codes are a relay feature — there's no one to mint them).
2115 pub fn create_connect_code(&self) -> Result<()> {
2116 if !self.network.create_connect_token() {
2117 return Err(HuddleError::Network(
2118 "not connected to the relay — can't create a connect code".into(),
2119 ));
2120 }
2121 Ok(())
2122 }
2123
2124 /// huddle 1.2.1: redeem a connect code someone shared. The relay resolves
2125 /// it to their identity and we send them a contact request (which opens a
2126 /// DM once they accept). Progress arrives as `AppEvent::ConnectCodeRedeemed`
2127 /// / `ConnectCodeFailed`. Errors immediately for a malformed code or when
2128 /// the relay isn't connected.
2129 pub fn redeem_connect_code(&self, code: &str) -> Result<()> {
2130 let norm = normalize_connect_code(code)
2131 .ok_or_else(|| HuddleError::Other("that doesn't look like a connect code".into()))?;
2132 if !self.network.redeem_connect_token(&norm) {
2133 return Err(HuddleError::Network(
2134 "not connected to the relay — can't redeem a connect code".into(),
2135 ));
2136 }
2137 Ok(())
2138 }
2139
2140 /// huddle 1.2.1: the relay resolved a connect code we redeemed. Validate the
2141 /// resolution, then send the owner a contact request (which opens a DM when
2142 /// they accept). Emits `ConnectCodeRedeemed` on success, `ConnectCodeFailed`
2143 /// otherwise.
2144 async fn on_connect_code_resolved(
2145 &self,
2146 fingerprint: Option<String>,
2147 pubkey_b64: Option<String>,
2148 ) {
2149 let our_fp = self.identity.fingerprint().to_string();
2150 let fp = match fingerprint {
2151 Some(fp) if !fp.is_empty() => fp,
2152 _ => {
2153 let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2154 reason: "invalid or expired connect code".into(),
2155 });
2156 return;
2157 }
2158 };
2159 if fp == our_fp {
2160 let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2161 reason: "that's your own connect code".into(),
2162 });
2163 return;
2164 }
2165 // Integrity check: if the relay also returned the owner's pubkey, it
2166 // MUST hash to the fingerprint it claims — else the mapping is bogus
2167 // (a buggy or hostile relay). The real identity proof still comes from
2168 // the owner's signed reply; this just rejects an obviously-wrong map.
2169 if let Some(pk_b64) = pubkey_b64.as_deref() {
2170 if let Some(pk) = B64
2171 .decode(pk_b64)
2172 .ok()
2173 .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
2174 {
2175 if crate::identity::compute_fingerprint(&pk) != fp {
2176 let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2177 reason: "connect code resolved to a mismatched identity".into(),
2178 });
2179 return;
2180 }
2181 }
2182 }
2183 match self.send_contact_request(&fp, None).await {
2184 Ok(()) => {
2185 let _ = self
2186 .app_event_tx
2187 .send(AppEvent::ConnectCodeRedeemed { fingerprint: fp });
2188 }
2189 Err(e) => {
2190 let _ = self.app_event_tx.send(AppEvent::ConnectCodeFailed {
2191 reason: format!("couldn't send the request: {e}"),
2192 });
2193 }
2194 }
2195 }
2196
2197 /// Decline a pending contact request. `block` also adds the requester to
2198 /// the persistent blocklist so they can't re-request.
2199 pub fn reject_contact_request(&self, fingerprint: &str, block: bool) -> Result<()> {
2200 repo::delete_pending_contact_request(&self.db, fingerprint)?;
2201 if block {
2202 repo::block_peer(&self.db, fingerprint, now_unix())?;
2203 }
2204 Ok(())
2205 }
2206
2207 /// Re-dial a stored address — used by the lobby's "reconnect" action.
2208 pub async fn redial(&self, address: &str) -> Result<()> {
2209 self.dial(address).await
2210 }
2211
2212 /// Phase A: user pressed Accept on the inbound-dial modal. Promotes
2213 /// the peer to the gossipsub mesh. Does NOT mark them trusted —
2214 /// that's `trust_inbound`, the explicit "remember and bypass next
2215 /// time" path.
2216 pub async fn accept_inbound(&self, peer_id: PeerId, address: &str) {
2217 self.network.accept_inbound(peer_id).await;
2218 self.connected_dial_addrs
2219 .lock()
2220 .unwrap()
2221 .insert(address.to_string(), peer_id);
2222 }
2223
2224 /// Phase A: user pressed Reject on the inbound-dial modal. Disconnects
2225 /// the peer, adds them to the persistent blocklist, and ensures every
2226 /// subsequent connection attempt from this fingerprint is auto-
2227 /// dropped without re-prompting.
2228 pub async fn reject_inbound(&self, peer_id: PeerId, fingerprint: &str) -> Result<()> {
2229 self.network.reject_inbound(peer_id).await;
2230 repo::block_peer(&self.db, fingerprint, now_unix())?;
2231 Ok(())
2232 }
2233
2234 /// Phase A: user pressed Trust+Accept — accept the connection AND
2235 /// remember the peer so subsequent connections bypass the modal.
2236 pub async fn trust_inbound(
2237 &self,
2238 peer_id: PeerId,
2239 fingerprint: &str,
2240 address: &str,
2241 ) -> Result<()> {
2242 self.network.accept_inbound(peer_id).await;
2243 self.connected_dial_addrs
2244 .lock()
2245 .unwrap()
2246 .insert(address.to_string(), peer_id);
2247 // Persist the row with trusted=true so future inbound from
2248 // this fingerprint short-circuits the modal in
2249 // `process_network_event`'s InboundDial handler.
2250 repo::upsert_known_peer(
2251 &self.db,
2252 &KnownPeer {
2253 address: address.to_string(),
2254 label: None,
2255 last_connected_at: Some(now_unix()),
2256 last_attempt_at: Some(now_unix()),
2257 created_at: now_unix(),
2258 fingerprint: Some(fingerprint.to_string()),
2259 trusted: true,
2260 },
2261 )?;
2262 // huddle 1.0: trusting a peer makes them a contact.
2263 let _ = self.add_contact(fingerprint, "dial");
2264 Ok(())
2265 }
2266
2267 // =========================================================================
2268 // huddle 0.7.7: pending friend requests (3-day TTL)
2269 // =========================================================================
2270
2271 /// Snapshot of every inbound dial we've spilled to disk but haven't
2272 /// yet accepted or rejected. The People pane renders this as its
2273 /// own section ("Pending requests (N)").
2274 pub fn list_pending_friend_requests(&self) -> Vec<repo::PendingFriendRequest> {
2275 repo::list_pending_friend_requests(&self.db).unwrap_or_default()
2276 }
2277
2278 /// Persist an inbound request that the user didn't act on within the
2279 /// modal window. Called from the TUI's idle-timeout sweep; the live
2280 /// libp2p connection is also closed by the same path (the request
2281 /// is effectively rejected *for now* — accept later from People
2282 /// pane will re-dial the stored address).
2283 pub fn spill_pending_friend_request(
2284 &self,
2285 peer_id: PeerId,
2286 fingerprint: &str,
2287 address: &str,
2288 ) -> Result<()> {
2289 repo::upsert_pending_friend_request(
2290 &self.db,
2291 &repo::PendingFriendRequest {
2292 fingerprint: fingerprint.to_string(),
2293 address: address.to_string(),
2294 peer_id: peer_id.to_string(),
2295 received_at: now_unix(),
2296 },
2297 )?;
2298 Ok(())
2299 }
2300
2301 /// User pressed Accept on a row in the Pending requests list. The
2302 /// original libp2p connection is long gone (we closed it on
2303 /// timeout); re-dial the stored address and mark the peer trusted
2304 /// so the post-Identify handler short-circuits the modal. The
2305 /// row is removed regardless of dial success — a failed dial is
2306 /// still a positive intent we don't want to keep re-prompting on.
2307 pub async fn accept_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
2308 let mut chosen_addr: Option<String> = None;
2309 for req in self.list_pending_friend_requests() {
2310 if req.fingerprint == fingerprint {
2311 chosen_addr = Some(req.address);
2312 break;
2313 }
2314 }
2315 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
2316 // huddle 1.0: accepting a friend request makes them a contact.
2317 let _ = self.add_contact(fingerprint, "request");
2318 if let Some(addr) = chosen_addr {
2319 // Pre-mark trusted so the upcoming Identify handler skips
2320 // the inbound-dial modal. Matches the semantics of
2321 // `trust_inbound` without needing a live PeerId.
2322 repo::upsert_known_peer(
2323 &self.db,
2324 &KnownPeer {
2325 address: addr.clone(),
2326 label: None,
2327 last_connected_at: None,
2328 last_attempt_at: Some(now_unix()),
2329 created_at: now_unix(),
2330 fingerprint: Some(fingerprint.to_string()),
2331 trusted: true,
2332 },
2333 )?;
2334 // User-initiated — register for auto-DM on connect.
2335 self.dial(&addr).await?;
2336 }
2337 Ok(())
2338 }
2339
2340 /// User pressed Reject on a row in the Pending requests list.
2341 /// Mirrors `reject_inbound` semantics: delete the pending row(s)
2342 /// AND block the fingerprint so any future dial from this peer is
2343 /// auto-dropped without re-prompting.
2344 pub fn reject_pending_friend_request(&self, fingerprint: &str) -> Result<()> {
2345 repo::delete_pending_friend_requests_for_fp(&self.db, fingerprint)?;
2346 repo::block_peer(&self.db, fingerprint, now_unix())?;
2347 Ok(())
2348 }
2349
2350 /// huddle 0.7.7: close a live libp2p connection without blocking the
2351 /// peer. Used by the TUI's 15s InboundDial timeout — we need to
2352 /// drop the dangling socket, but blocking the peer would
2353 /// contradict "save the request for 3 days, let the user decide
2354 /// later." `reject_inbound` is the right call when the user
2355 /// *explicitly* clicks Reject.
2356 pub async fn disconnect_peer(&self, peer_id: PeerId) {
2357 self.network.disconnect_peer(peer_id).await;
2358 }
2359
2360 fn spawn_known_peer_reconnector(&self) {
2361 let handle = self.clone();
2362 tokio::spawn(async move {
2363 // Brief delay so our own listeners come up first.
2364 tokio::time::sleep(Duration::from_millis(500)).await;
2365 let known = repo::list_known_peers(&handle.db).unwrap_or_default();
2366 // Reconnect each peer from its own task on a staggered, jittered
2367 // delay so a long known-peer list doesn't fire a synchronized
2368 // burst of dials (and serialized DB writes) all at once.
2369 for (i, peer) in known.into_iter().enumerate() {
2370 let handle = handle.clone();
2371 tokio::spawn(async move {
2372 // Deterministic per-address jitter de-correlates peers
2373 // without pulling an RNG into scope.
2374 let jitter = (peer.address.len() as u64 * 37) % 200;
2375 tokio::time::sleep(Duration::from_millis(150 * i as u64 + jitter)).await;
2376 // huddle 0.7.7: route through `dial_internal`, NOT
2377 // `dial`. Startup reconnects shouldn't pop a DM
2378 // every time a known peer comes online — only
2379 // explicit user actions trigger the auto-DM.
2380 let multiaddr = match peer.address.parse::<Multiaddr>() {
2381 Ok(m) => m,
2382 Err(_) => return,
2383 };
2384 if let Err(e) = handle.dial_internal(peer.address.clone(), multiaddr).await {
2385 debug!(%e, addr = %peer.address, "auto-reconnect failed");
2386 }
2387 });
2388 }
2389 });
2390 }
2391
2392 // -------------------------------------------------------------------
2393 // Internal helpers
2394 // -------------------------------------------------------------------
2395
2396 fn load_or_create_identity(db: &Db) -> Result<Identity> {
2397 if let Some(stored) = repo::load_identity(db)? {
2398 let mut bytes = [0u8; 32];
2399 bytes.copy_from_slice(&stored.ed25519_secret);
2400 Identity::from_secret_bytes(bytes)
2401 } else {
2402 let id = Identity::generate()?;
2403 repo::save_identity(db, &id.secret_bytes(), now_unix())?;
2404 Ok(id)
2405 }
2406 }
2407
2408 fn get_room_salt(&self, room_id: &str) -> Option<Vec<u8>> {
2409 self.active_rooms
2410 .lock()
2411 .unwrap()
2412 .get(room_id)
2413 .and_then(|r| r.info.passphrase_salt.clone())
2414 .or_else(|| {
2415 // Try the cached announcement salt
2416 ROOM_SALT_CACHE
2417 .lock()
2418 .unwrap()
2419 .get(room_id)
2420 .cloned()
2421 })
2422 }
2423
2424 async fn announce_room_now(&self, info: &StoredRoom, member_count: u32) {
2425 let owner_fingerprints =
2426 repo::list_room_owners(&self.db, &info.id).unwrap_or_default();
2427 let verified_only = repo::get_room_verified_only(&self.db, &info.id).unwrap_or(false);
2428 let host_addrs = self.dialable_addrs();
2429 let ann = RoomAnnouncement {
2430 room_id: info.id.clone(),
2431 name: info.name.clone(),
2432 encrypted: info.encrypted,
2433 passphrase_salt: info.passphrase_salt.clone(),
2434 member_count,
2435 creator_fingerprint: info.creator_fingerprint.clone(),
2436 announced_at: now_unix(),
2437 owner_fingerprints,
2438 verified_only,
2439 host_addrs,
2440 kind: info.kind,
2441 };
2442 self.network.announce_room(ann).await;
2443 }
2444
2445 async fn broadcast_member_announce(&self, room_id: &str) -> Result<()> {
2446 let our_fp = self.identity.fingerprint().to_string();
2447 let wrapped = {
2448 let mut rooms = self.active_rooms.lock().unwrap();
2449 let room = rooms
2450 .get_mut(room_id)
2451 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
2452 if room.info.encrypted {
2453 let crypto = room.crypto.as_mut().unwrap();
2454 let session_key = crypto.our_session_key_b64();
2455 match room.passphrase_key.as_ref() {
2456 Some(passphrase_key) => {
2457 Some(passphrase::wrap(session_key.as_bytes(), passphrase_key)?)
2458 }
2459 None if room.info.kind == RoomKind::Direct => {
2460 // huddle 0.7.1: DM-specific path — partner's
2461 // pubkey hasn't been observed yet, so we can't
2462 // derive the ECDH key. Send announce without
2463 // a wrapped key — it carries our Ed25519
2464 // pubkey, which lets the partner derive the
2465 // key on their side. They'll respond with
2466 // their own wrapped key in a follow-up
2467 // announce; once we receive it we re-broadcast
2468 // ours with the wrap filled in.
2469 None
2470 }
2471 None => {
2472 return Err(HuddleError::Session("missing passphrase key".into()));
2473 }
2474 }
2475 } else {
2476 None
2477 }
2478 };
2479 let display_name = repo::get_display_name(&self.db).unwrap_or(None);
2480 let msg = RoomMessage::MemberAnnounce {
2481 sender_fingerprint: our_fp,
2482 wrapped_session_key: wrapped,
2483 display_name,
2484 sender_ed25519_pubkey: Some(B64.encode(self.identity.public_bytes())),
2485 };
2486 // huddle 0.7.11: MemberAnnounce is now signed end-to-end. The
2487 // envelope's Ed25519 pubkey is the canonical TOFU pin for this
2488 // fingerprint; the inner `sender_ed25519_pubkey` field stays
2489 // present for back-compat parsing but is no longer authoritative.
2490 let env = crate::crypto::sign_message(&self.identity, &msg)?;
2491 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
2492 self.network
2493 .publish_room_message(room_id.to_string(), bytes)
2494 .await;
2495 Ok(())
2496 }
2497
2498 fn spawn_event_processor(&self, mut net_rx: tokio::sync::mpsc::Receiver<NetworkEvent>) {
2499 let handle = self.clone();
2500 tokio::spawn(async move {
2501 while let Some(event) = net_rx.recv().await {
2502 handle.process_network_event(event).await;
2503 }
2504 info!("event processor stopped");
2505 });
2506 }
2507
2508 /// huddle 0.8/1.0: maintain a connection to the relay backend for the
2509 /// life of the process. Reconnects with capped exponential backoff. Each
2510 /// attempt tries the transport "doors" in `order` (onion first, clearnet
2511 /// last, or a single pinned door) until one connects — so a censored user
2512 /// whose Tor is blocked transparently falls through to a clearnet door.
2513 /// While connected, the [`NetworkHandle`] mirrors outgoing room traffic
2514 /// to it (see `attach_server`), and incoming server messages are funneled
2515 /// into the *same* `RoomMessageReceived` handler as gossipsub — so a
2516 /// message arriving via the relay is decoded, verified, and decrypted by
2517 /// exactly the same code path. The live door is recorded in
2518 /// `active_transport` for the UI/CLI.
2519 /// huddle 1.2: every room id whose membership must be asserted on the
2520 /// relay — active rooms, rooms parked as `restorable` (encrypted groups /
2521 /// keyless DMs awaiting a passphrase or the partner's pubkey), and the aux
2522 /// subscriptions (our own contact inbox). Used both to build the Hello
2523 /// room set and to re-subscribe after each (re)connect, so the relay knows
2524 /// we belong to a room even before we can decrypt it — otherwise its
2525 /// fan-out skips us and group messages silently never arrive.
2526 fn relay_membership_ids(&self) -> Vec<String> {
2527 let mut set: HashSet<String> =
2528 self.active_rooms.lock().unwrap().keys().cloned().collect();
2529 set.extend(self.restorable_rooms.lock().unwrap().keys().cloned());
2530 set.extend(self.aux_subscriptions.lock().unwrap().iter().cloned());
2531 set.into_iter().collect()
2532 }
2533
2534 fn spawn_server_connection(&self, order: Vec<TransportId>) {
2535 let handle = self.clone();
2536 tokio::spawn(async move {
2537 let mut backoff = 1u64;
2538 loop {
2539 // huddle 1.0: the Hello room set is every active chat room
2540 // PLUS our aux subscriptions (the contact inbox), so the relay
2541 // re-registers inbox membership on every reconnect and flushes
2542 // any queued contact requests.
2543 let rooms: Vec<String> = handle.relay_membership_ids();
2544
2545 // Try each door in order until one connects. Unavailable
2546 // doors (no URL / wrong build) are skipped.
2547 let mut connected: Option<(
2548 ServerClient,
2549 tokio::sync::mpsc::UnboundedReceiver<ServerEvent>,
2550 TransportId,
2551 )> = None;
2552 for id in &order {
2553 let (url, dial) = match handle
2554 .transport_profiles
2555 .iter()
2556 .find(|p| p.id == *id)
2557 {
2558 Some(p) if p.available() => {
2559 (p.url.clone().unwrap(), p.dial.clone().unwrap())
2560 }
2561 _ => continue,
2562 };
2563 match ServerClient::connect(
2564 &url,
2565 &dial,
2566 handle.identity.clone(),
2567 rooms.clone(),
2568 )
2569 .await
2570 {
2571 Ok((client, rx)) => {
2572 info!(%url, transport = id.as_str(), "connected to relay");
2573 connected = Some((client, rx, *id));
2574 break;
2575 }
2576 Err(e) => {
2577 debug!(error = %e, transport = id.as_str(), %url, "relay door failed; trying next");
2578 }
2579 }
2580 }
2581
2582 if let Some((client, mut rx, id)) = connected {
2583 backoff = 1;
2584 handle.network.attach_server(client);
2585 *handle.active_transport.lock().unwrap() = Some(id);
2586 // huddle 1.2: re-assert membership for every active room
2587 // over the freshly attached connection. Hello carried the
2588 // room snapshot taken before we connected, so a room
2589 // created/joined during the connect-handshake window would
2590 // otherwise stay unknown to the relay until the next
2591 // reconnect — silently breaking group fan-out for it. The
2592 // relay's add_membership is idempotent, so re-subscribing is
2593 // free. (DM rooms route by fingerprint and don't depend on
2594 // this, but re-subscribing them is harmless.)
2595 for rid in handle.relay_membership_ids() {
2596 handle.network.subscribe_room(rid).await;
2597 }
2598 while let Some(ev) = rx.recv().await {
2599 match ev {
2600 ServerEvent::Message { room, payload, .. } => {
2601 handle
2602 .process_network_event(NetworkEvent::RoomMessageReceived {
2603 room_id: room,
2604 payload,
2605 from_peer: PeerId::random(),
2606 })
2607 .await;
2608 }
2609 ServerEvent::Ready | ServerEvent::Sent { .. } => {}
2610 ServerEvent::ConnectToken { token, ttl_secs } => {
2611 // huddle 1.2.1: relay minted our connect code.
2612 let expires_at = now_unix() + ttl_secs as i64;
2613 let _ = handle.app_event_tx.send(AppEvent::ConnectCodeCreated {
2614 code: token,
2615 expires_at,
2616 });
2617 }
2618 ServerEvent::ConnectTokenResolved {
2619 fingerprint,
2620 pubkey_b64,
2621 } => {
2622 handle.on_connect_code_resolved(fingerprint, pubkey_b64).await;
2623 }
2624 ServerEvent::Disconnected => break,
2625 }
2626 }
2627 handle.network.detach_server();
2628 *handle.active_transport.lock().unwrap() = None;
2629 warn!("relay connection closed; reconnecting");
2630 } else {
2631 warn!("all relay doors failed; will retry");
2632 }
2633 tokio::time::sleep(Duration::from_secs(backoff)).await;
2634 backoff = (backoff * 2).min(30);
2635 }
2636 });
2637 }
2638
2639 fn spawn_announcement_ticker(&self) {
2640 let handle = self.clone();
2641 tokio::spawn(async move {
2642 let mut interval =
2643 tokio::time::interval(Duration::from_secs(ANNOUNCE_INTERVAL_SECS));
2644 interval.tick().await; // skip the immediate tick
2645 loop {
2646 interval.tick().await;
2647 let snapshot: Vec<(StoredRoom, u32)> = {
2648 let active = handle.active_rooms.lock().unwrap();
2649 active
2650 .values()
2651 .map(|r| (r.info.clone(), r.members.len() as u32))
2652 .collect()
2653 };
2654 for (info, member_count) in snapshot {
2655 handle.announce_room_now(&info, member_count).await;
2656 }
2657 }
2658 });
2659 }
2660
2661 fn spawn_discovered_room_pruner(&self) {
2662 let handle = self.clone();
2663 tokio::spawn(async move {
2664 let mut interval = tokio::time::interval(Duration::from_secs(10));
2665 interval.tick().await;
2666 loop {
2667 interval.tick().await;
2668 let now = now_unix();
2669 let mut to_drop = Vec::new();
2670 {
2671 let mut map = handle.discovered_rooms.lock().unwrap();
2672 map.retain(|id, r| {
2673 if now - r.last_seen > DISCOVERED_TTL_SECS {
2674 to_drop.push(id.clone());
2675 false
2676 } else {
2677 true
2678 }
2679 });
2680 }
2681 for id in to_drop {
2682 let _ = handle.app_event_tx.send(AppEvent::RoomLost { room_id: id });
2683 }
2684 }
2685 });
2686 }
2687
2688 async fn process_network_event(&self, event: NetworkEvent) {
2689 match event {
2690 NetworkEvent::PeerDiscovered { peer_id } => {
2691 let _ = self.app_event_tx.send(AppEvent::PeerDiscovered { peer_id });
2692 }
2693 NetworkEvent::PeerExpired { peer_id } => {
2694 // Drop any tracked dial-connection entry for this peer so
2695 // the lobby's online/offline dots stay accurate. mDNS
2696 // expiry only gives us a PeerId (no fingerprint), so we
2697 // can't touch room membership here — that relies on the
2698 // explicit MemberLeave path and the discovered-room TTL.
2699 self.connected_dial_addrs
2700 .lock()
2701 .unwrap()
2702 .retain(|_addr, pid| *pid != peer_id);
2703 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
2704 }
2705 NetworkEvent::PeerDisconnected { peer_id } => {
2706 // huddle 0.7.11: relay / internet peers don't trigger
2707 // mDNS PeerExpired, so without this their entries in
2708 // connected_dial_addrs stayed forever and the lobby
2709 // showed them as "● online" indefinitely after they
2710 // dropped. Same cleanup shape as PeerExpired.
2711 self.connected_dial_addrs
2712 .lock()
2713 .unwrap()
2714 .retain(|_addr, pid| *pid != peer_id);
2715 let _ = self.app_event_tx.send(AppEvent::PeerExpired { peer_id });
2716 }
2717 // huddle 0.7.12: `RelayReservationLost` was removed —
2718 // libp2p 0.56's relay client doesn't surface a failure
2719 // variant we can listen on. Reservation loss currently
2720 // manifests as the next AutoNAT probe flipping to
2721 // "private" once the circuit drops; a future health-
2722 // check timer can re-introduce the dedicated signal.
2723 NetworkEvent::ListeningOn { address } => {
2724 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
2725 address: address.to_string(),
2726 });
2727 }
2728 NetworkEvent::RoomAnnouncementReceived(ann) => {
2729 // Cache the salt for join_room
2730 if let Some(salt) = &ann.passphrase_salt {
2731 remember_room_salt(&ann.room_id, salt.clone());
2732 }
2733 // Phase D follow-up: opportunistically dial the
2734 // announcer's first host_addr if we're not already
2735 // connected. Skips self-announcements + rate-limits
2736 // by creator fingerprint so we don't dial-storm.
2737 let our_fp_for_dial = self.identity.fingerprint().to_string();
2738 if ann.creator_fingerprint != our_fp_for_dial && !ann.host_addrs.is_empty() {
2739 let now = now_unix();
2740 let should_dial = {
2741 let mut attempts = self.host_addr_dial_attempts.lock().unwrap();
2742 match attempts.get(&ann.creator_fingerprint).copied() {
2743 Some(last) if now - last < HOST_ADDR_DIAL_BACKOFF_SECS => false,
2744 _ => {
2745 attempts.insert(ann.creator_fingerprint.clone(), now);
2746 true
2747 }
2748 }
2749 };
2750 if should_dial {
2751 if let Some(first) = ann.host_addrs.first() {
2752 info!(
2753 announcer = %ann.creator_fingerprint,
2754 addr = %first,
2755 "opportunistic dial via room announcement host_addrs"
2756 );
2757 // huddle 0.7.7: NOT user-initiated — go
2758 // through `dial_internal` so a passive
2759 // announcement-driven dial doesn't pop a
2760 // DM in the user's face.
2761 if let Ok(multiaddr) = first.parse::<Multiaddr>() {
2762 let canonical = multiaddr.to_string();
2763 let _ = self.dial_internal(canonical, multiaddr).await;
2764 }
2765 }
2766 }
2767 }
2768 let discovered = DiscoveredRoom {
2769 room_id: ann.room_id.clone(),
2770 name: ann.name.clone(),
2771 encrypted: ann.encrypted,
2772 member_count: ann.member_count,
2773 creator_fingerprint: ann.creator_fingerprint.clone(),
2774 last_seen: now_unix(),
2775 restorable: false,
2776 host_addrs: ann.host_addrs.clone(),
2777 kind: ann.kind,
2778 };
2779 // If we're already in this room, cache the announcement so
2780 // others can still discover it through us, but don't emit
2781 // RoomDiscovered — it isn't "newly discovered" to us, and
2782 // emitting it spuriously re-opens the lobby join prompt.
2783 if self.active_rooms.lock().unwrap().contains_key(&ann.room_id) {
2784 self.discovered_rooms
2785 .lock()
2786 .unwrap()
2787 .insert(ann.room_id.clone(), discovered);
2788 return;
2789 }
2790 // huddle 0.7 DM-visibility filter (consumer side): a
2791 // `Direct` announcement is only valid for the two members
2792 // implied by `canonical_dm_room_id`. If we're not one of
2793 // them, silently drop — DMs never appear in third
2794 // parties' discovery caches. A malicious 0.7+ peer can
2795 // ignore this, but they'd have to subscribe to the
2796 // canonical DM topic with full knowledge of both
2797 // fingerprints, which is a stronger threat than the v1
2798 // sidebar split is trying to mitigate.
2799 if ann.kind == RoomKind::Direct {
2800 let our_fp_for_filter = self.identity.fingerprint().to_string();
2801 if canonical_dm_room_id(&our_fp_for_filter, &ann.creator_fingerprint)
2802 != ann.room_id
2803 {
2804 debug!(
2805 announcer = %ann.creator_fingerprint,
2806 room_id = %ann.room_id,
2807 "dropping Direct announcement: not addressed to us"
2808 );
2809 return;
2810 }
2811 // Targeted at us. Cache the discovery so the sidebar
2812 // can show "DM from <partner>" and auto-bootstrap a
2813 // local active room so we can receive messages
2814 // immediately without waiting for a user action.
2815 //
2816 // huddle 0.7.11: drop the auto-bootstrap if the
2817 // partner is on the persistent blocklist. Without
2818 // this gate, a blocked peer could re-introduce
2819 // themselves into our sidebar simply by re-announcing
2820 // the DM topic; we'd subscribe and persist a row for
2821 // them before any user action.
2822 if repo::is_peer_blocked(&self.db, &ann.creator_fingerprint).unwrap_or(false)
2823 {
2824 debug!(
2825 partner = %ann.creator_fingerprint,
2826 "ignoring Direct announcement from blocked peer"
2827 );
2828 return;
2829 }
2830 self.discovered_rooms
2831 .lock()
2832 .unwrap()
2833 .insert(ann.room_id.clone(), discovered.clone());
2834 let _ = self
2835 .app_event_tx
2836 .send(AppEvent::RoomDiscovered(discovered.clone()));
2837 let app = self.clone();
2838 let partner = ann.creator_fingerprint.clone();
2839 let rid = ann.room_id.clone();
2840 tokio::spawn(async move {
2841 if let Err(e) = app.start_direct(&partner).await {
2842 debug!(%e, room_id = %rid, "auto-bootstrap of inbound DM failed");
2843 }
2844 });
2845 return;
2846 }
2847 self.discovered_rooms
2848 .lock()
2849 .unwrap()
2850 .insert(ann.room_id.clone(), discovered.clone());
2851 let _ = self.app_event_tx.send(AppEvent::RoomDiscovered(discovered));
2852 }
2853 NetworkEvent::RoomMessageReceived {
2854 room_id,
2855 payload,
2856 from_peer: _,
2857 } => {
2858 // v0.3.0+: every wire message is a `WireMessage` envelope.
2859 // `Plain` carries an unsigned `RoomMessage`; `Signed` is an
2860 // app-level Ed25519 envelope that we verify before
2861 // unwrapping. A failed verify is logged and dropped — we
2862 // never dispatch unverified-but-claiming-to-be-signed
2863 // messages.
2864 let wire: WireMessage = match serde_json::from_slice(&payload) {
2865 Ok(w) => w,
2866 Err(e) => {
2867 warn!(%e, "bad wire envelope");
2868 return;
2869 }
2870 };
2871 let (msg, verified_signer) = match wire {
2872 WireMessage::Plain(m) => (m, None),
2873 WireMessage::Signed(env) => {
2874 let claimed_pubkey = env.ed25519_pubkey_b64.clone();
2875 match crate::crypto::verify_signed(&env) {
2876 Ok((m, fp)) => {
2877 // Defense in depth: if we've persisted
2878 // a pubkey for this fingerprint in this
2879 // room before, the envelope's pubkey
2880 // MUST match it. A different pubkey for
2881 // the same fingerprint means identity
2882 // drift — TOFU violation — drop.
2883 match repo::get_member_ed25519_pubkey(
2884 &self.db, &room_id, &fp,
2885 ) {
2886 Ok(Some(known)) if known != claimed_pubkey => {
2887 warn!(
2888 %fp, %room_id,
2889 "pubkey mismatch vs stored; dropping signed message"
2890 );
2891 return;
2892 }
2893 _ => {}
2894 }
2895 (m, Some(fp))
2896 }
2897 Err(e) => {
2898 warn!(%e, fp = %env.fingerprint, "signed envelope verify failed");
2899 return;
2900 }
2901 }
2902 }
2903 };
2904 self.handle_room_message(&room_id, msg, verified_signer).await;
2905 }
2906 NetworkEvent::DialSucceeded { peer_id, address } => {
2907 let addr_s = address.to_string();
2908 self.connected_dial_addrs
2909 .lock()
2910 .unwrap()
2911 .insert(addr_s.clone(), peer_id);
2912 // Fingerprint isn't known yet (Identify hasn't landed);
2913 // the PeerIdentified handler below upserts again to add
2914 // the fingerprint and flip trusted=true once it does.
2915 let _ = repo::upsert_known_peer(
2916 &self.db,
2917 &KnownPeer {
2918 address: addr_s.clone(),
2919 label: None,
2920 last_connected_at: Some(now_unix()),
2921 last_attempt_at: Some(now_unix()),
2922 created_at: now_unix(),
2923 fingerprint: None,
2924 trusted: false,
2925 },
2926 );
2927 let _ = self.app_event_tx.send(AppEvent::DialSucceeded {
2928 address: addr_s,
2929 peer_id,
2930 });
2931 }
2932 NetworkEvent::DialFailed { address, error } => {
2933 let addr_s = address.to_string();
2934 let _ = self.app_event_tx.send(AppEvent::DialFailed {
2935 address: addr_s,
2936 error,
2937 });
2938 }
2939 NetworkEvent::PeerIdentified { peer_id, fingerprint } => {
2940 // For any address we user-dialed for this peer, retroactively
2941 // backfill the fingerprint and flip trusted=true. The
2942 // upsert's COALESCE preserves fingerprint once set and
2943 // its trusted-is-sticky-once-true clause means we don't
2944 // accidentally demote a row that was already trusted.
2945 let matched_addrs: Vec<String> = {
2946 let map = self.connected_dial_addrs.lock().unwrap();
2947 map.iter()
2948 .filter_map(|(addr, pid)| {
2949 if *pid == peer_id {
2950 Some(addr.clone())
2951 } else {
2952 None
2953 }
2954 })
2955 .collect()
2956 };
2957 // Phase C follow-up: if any of these addresses came
2958 // from an invite, verify the invite's claimed fp
2959 // against what we just derived from the pubkey. A
2960 // mismatch means the invite's fp label disagrees with
2961 // libp2p's /p2p/<peer-id> cryptographic anchor —
2962 // structurally impossible when both fields are
2963 // generated from the same identity, but the explicit
2964 // assert defends against future invite-format
2965 // changes or hand-edited links.
2966 let mismatch = {
2967 let mut map = self.pending_invite_dials.lock().unwrap();
2968 let mut found: Option<(String, String)> = None;
2969 for addr in &matched_addrs {
2970 if let Some(claimed) = map.remove(addr) {
2971 if claimed != fingerprint {
2972 found = Some((addr.clone(), claimed));
2973 break;
2974 }
2975 }
2976 }
2977 found
2978 };
2979 if let Some((addr, claimed)) = mismatch {
2980 warn!(
2981 %addr, %claimed, actual=%fingerprint,
2982 "invite fingerprint mismatch — disconnecting"
2983 );
2984 self.network.disconnect_peer(peer_id).await;
2985 let _ = self.app_event_tx.send(AppEvent::InviteFingerprintMismatch {
2986 address: addr,
2987 claimed,
2988 actual: fingerprint.clone(),
2989 });
2990 return;
2991 }
2992 // huddle 0.7.7: did the local user initiate any of these
2993 // dials? If so, consume the matching entries from
2994 // `pending_auto_dm_addrs` now so we don't auto-DM
2995 // again on a subsequent reconnect. The actual DM
2996 // start happens after the trust upsert below so the
2997 // peer is already marked trusted by the time we fire.
2998 let should_auto_dm = {
2999 let mut pending = self.pending_auto_dm_addrs.lock().unwrap();
3000 let mut any_matched = false;
3001 for addr in &matched_addrs {
3002 if pending.remove(addr) {
3003 any_matched = true;
3004 }
3005 }
3006 any_matched
3007 };
3008 for addr in matched_addrs {
3009 let _ = repo::upsert_known_peer(
3010 &self.db,
3011 &KnownPeer {
3012 address: addr,
3013 label: None,
3014 last_connected_at: Some(now_unix()),
3015 last_attempt_at: Some(now_unix()),
3016 created_at: now_unix(),
3017 fingerprint: Some(fingerprint.clone()),
3018 trusted: true,
3019 },
3020 );
3021 }
3022 // huddle 0.7.7: open (or reuse) a DM with the freshly
3023 // identified peer and tell the TUI to switch panes.
3024 // `start_direct` is idempotent on `canonical_dm_room_id`,
3025 // so this is safe to call even if a DM already exists.
3026 //
3027 // huddle 0.7.11: explicitly gate on the persistent
3028 // blocklist here. The original comment claimed blocked
3029 // peers "fall through naturally" but that was only true
3030 // for *inbound* dials — the block check at line ~2237
3031 // is inbound-only. Outbound user-dials hit Identify and
3032 // landed here without ever consulting the blocklist,
3033 // bypassing the user's explicit block.
3034 let blocked = repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false);
3035 if should_auto_dm && !blocked && fingerprint != self.identity.fingerprint() {
3036 match self.start_direct(&fingerprint).await {
3037 Ok(room_id) => {
3038 let _ = self.app_event_tx.send(AppEvent::AutoOpenDm {
3039 room_id,
3040 fingerprint: fingerprint.clone(),
3041 });
3042 }
3043 Err(e) => {
3044 debug!(%e, fp = %fingerprint, "auto-DM after dial failed");
3045 }
3046 }
3047 }
3048 // huddle 0.5: tell the newly-identified peer our current
3049 // username via a signed ProfileUpdate, but only if we
3050 // have one set locally and we haven't already pushed
3051 // ours to this peer in the last
3052 // `PROFILE_REBROADCAST_FLOOR_MS`. Without the floor a
3053 // flapping transport (relay reconnect storms) would
3054 // republish on every identify event.
3055 let our_username = repo::get_display_name(&self.db).unwrap_or(None);
3056 if our_username.is_some() {
3057 let now_ms = now_unix_ms();
3058 let should_send = {
3059 let mut last = self.last_profile_broadcast_at_ms.lock().unwrap();
3060 match last.get(&fingerprint) {
3061 Some(prev) if now_ms - prev < PROFILE_REBROADCAST_FLOOR_MS => false,
3062 _ => {
3063 last.insert(fingerprint.clone(), now_ms);
3064 true
3065 }
3066 }
3067 };
3068 if should_send {
3069 let msg = RoomMessage::ProfileUpdate {
3070 sender_fingerprint: self.identity.fingerprint().to_string(),
3071 username: our_username,
3072 updated_at: now_ms,
3073 };
3074 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
3075 if let Ok(bytes) =
3076 crate::network::protocol::encode_wire_signed(&env)
3077 {
3078 let rooms: Vec<String> = self
3079 .active_rooms
3080 .lock()
3081 .unwrap()
3082 .keys()
3083 .cloned()
3084 .collect();
3085 for room_id in rooms {
3086 self.network
3087 .publish_room_message(room_id, bytes.clone())
3088 .await;
3089 }
3090 }
3091 }
3092 }
3093 }
3094 }
3095 NetworkEvent::RelayReservationEstablished { address } => {
3096 // Treat the circuit address like any other listen
3097 // address — the TUI's ListeningOn handler dedups + adds
3098 // it to the addresses pane. Also emit a status hint via
3099 // ListeningOn so the lobby's reachability line updates.
3100 info!(addr = %address, "relay reservation established");
3101 self.relay_circuit_addrs
3102 .lock()
3103 .unwrap()
3104 .insert(address.to_string());
3105 let _ = self.app_event_tx.send(AppEvent::ListeningOn {
3106 address: address.to_string(),
3107 });
3108 }
3109 NetworkEvent::NatProbeResult {
3110 tested_addr,
3111 reachable,
3112 } => {
3113 let addr_s = tested_addr.to_string();
3114 let (transitioned, becomes_reachable) = {
3115 let mut set = self.nat_reachable_addrs.lock().unwrap();
3116 let was_empty = set.is_empty();
3117 if reachable {
3118 set.insert(addr_s.clone());
3119 } else {
3120 set.remove(&addr_s);
3121 }
3122 let is_empty = set.is_empty();
3123 (was_empty != is_empty, !is_empty)
3124 };
3125 if transitioned {
3126 let label = if becomes_reachable {
3127 "reachable".to_string()
3128 } else {
3129 "private".to_string()
3130 };
3131 info!(reachable = %becomes_reachable, "NAT reachability changed");
3132 let _ = self.app_event_tx.send(AppEvent::NatStatusChanged {
3133 label,
3134 reachable: becomes_reachable,
3135 });
3136 }
3137 }
3138 NetworkEvent::DcutrUpgrade {
3139 remote_peer,
3140 success,
3141 } => {
3142 if success {
3143 // Render the peer as the last 8 chars of the
3144 // PeerId for compactness — full peer id is too long
3145 // for a status line.
3146 let s = remote_peer.to_base58();
3147 let tail: String = s.chars().rev().take(8).collect::<String>()
3148 .chars()
3149 .rev()
3150 .collect();
3151 let _ = self.app_event_tx.send(AppEvent::DcutrSucceeded {
3152 peer_label: tail,
3153 });
3154 }
3155 }
3156 NetworkEvent::InboundDial {
3157 peer_id,
3158 fingerprint,
3159 address,
3160 } => {
3161 // First: cheap server-side filters before bothering the user.
3162 if repo::is_peer_blocked(&self.db, &fingerprint).unwrap_or(false) {
3163 info!(%fingerprint, "inbound dial auto-rejected: peer is blocked");
3164 self.network.reject_inbound(peer_id).await;
3165 return;
3166 }
3167 // Phase E: global verified-only inbound mode. If on,
3168 // reject any unverified fingerprint without prompting.
3169 // SAS-verified (Phase G) and already-trusted (Phase A)
3170 // peers still come through.
3171 let global_verified_only =
3172 repo::get_setting(&self.db, "verified_only_inbound")
3173 .ok()
3174 .flatten()
3175 .map(|v| v == "1")
3176 .unwrap_or(false);
3177 if global_verified_only {
3178 let is_verified =
3179 repo::is_globally_verified(&self.db, &fingerprint).unwrap_or(false)
3180 || repo::is_fingerprint_trusted(&self.db, &fingerprint)
3181 .unwrap_or(false);
3182 if !is_verified {
3183 info!(
3184 %fingerprint,
3185 "inbound dial auto-rejected: verified-only mode"
3186 );
3187 self.network.reject_inbound(peer_id).await;
3188 return;
3189 }
3190 }
3191 if repo::is_fingerprint_trusted(&self.db, &fingerprint).unwrap_or(false) {
3192 info!(%fingerprint, "inbound dial auto-accepted: peer is trusted");
3193 // Persist the address → peer_id mapping just as a
3194 // user-dial would, so the lobby's online dot lights up.
3195 self.connected_dial_addrs
3196 .lock()
3197 .unwrap()
3198 .insert(address.to_string(), peer_id);
3199 let _ = repo::upsert_known_peer(
3200 &self.db,
3201 &KnownPeer {
3202 address: address.to_string(),
3203 label: None,
3204 last_connected_at: Some(now_unix()),
3205 last_attempt_at: Some(now_unix()),
3206 created_at: now_unix(),
3207 fingerprint: Some(fingerprint),
3208 trusted: true,
3209 },
3210 );
3211 self.network.accept_inbound(peer_id).await;
3212 return;
3213 }
3214 // Unknown peer — surface the modal in the TUI.
3215 let _ = self.app_event_tx.send(AppEvent::InboundDial {
3216 peer_id,
3217 fingerprint,
3218 address: address.to_string(),
3219 });
3220 }
3221 }
3222 }
3223
3224 /// `verified_signer` is `Some(fp)` if this message arrived inside a
3225 /// successfully-verified `WireMessage::Signed` envelope — in which
3226 /// case the inner sender_fingerprint *must* match. `None` for
3227 /// `WireMessage::Plain`. Phase B's `OwnerGrant`/`BanMember` arms
3228 /// require it to be `Some` AND the signer to be a current owner.
3229 ///
3230 /// INVARIANT (huddle 1.1.4): never hold a `std::sync::Mutex` guard
3231 /// (`active_rooms`, `sas_flows`, the DB) across an `.await`. Always
3232 /// scope the guard in its own block or `drop()` it before awaiting —
3233 /// see the DM-key path below. This is also enforced mechanically:
3234 /// this fn runs inside a `Send` task, so a `!Send` `MutexGuard` held
3235 /// across `.await` would fail to compile.
3236 async fn handle_room_message(
3237 &self,
3238 room_id: &str,
3239 msg: RoomMessage,
3240 verified_signer: Option<String>,
3241 ) {
3242 let our_fp = self.identity.fingerprint().to_string();
3243 // huddle 1.2: lazily re-activate a known DM that isn't currently in
3244 // active_rooms before dispatching. Otherwise the first inbound message
3245 // or MemberAnnounce (which carries the session key!) for a DM that was
3246 // parked as `restorable` (partner pubkey unknown at restore) or simply
3247 // closed this session is silently dropped by the per-arm
3248 // `active_rooms.get(room_id) -> None => return` guards — and the DM
3249 // appears dead. Only DM rooms that ALREADY exist on disk with a known
3250 // partner are auto-activated here; group rooms (which need a
3251 // passphrase) and unknown rooms are left untouched.
3252 {
3253 let known_inactive = !self.active_rooms.lock().unwrap().contains_key(room_id);
3254 if known_inactive {
3255 if let Ok(Some(info)) = repo::get_room(&self.db, room_id) {
3256 if info.kind == RoomKind::Direct {
3257 let partner = repo::list_room_members(&self.db, room_id)
3258 .ok()
3259 .into_iter()
3260 .flatten()
3261 .map(|m| m.fingerprint)
3262 .find(|fp| *fp != our_fp);
3263 if let Some(partner_fp) = partner {
3264 if let Err(e) =
3265 self.bootstrap_direct_room(room_id, &partner_fp).await
3266 {
3267 debug!(%e, %room_id, "lazy DM re-activation on inbound failed");
3268 }
3269 }
3270 }
3271 }
3272 }
3273 }
3274 match msg {
3275 RoomMessage::MemberAnnounce {
3276 sender_fingerprint,
3277 wrapped_session_key,
3278 display_name,
3279 sender_ed25519_pubkey,
3280 } => {
3281 if sender_fingerprint == our_fp {
3282 return;
3283 }
3284 // huddle 0.7.11: MemberAnnounce must arrive inside a
3285 // signed envelope, and the signer's fingerprint must
3286 // match the claimed announcer. Closes the TOFU-pubkey
3287 // hijack: pre-0.7.11 a malicious peer could race a
3288 // victim's first announce on a room and pin a fabricated
3289 // ed25519 pubkey under the victim's fingerprint, so honest
3290 // peers would later reject the real victim's signed
3291 // messages. Now the inner `sender_ed25519_pubkey` is
3292 // ignored — the envelope's pubkey is the authoritative one.
3293 let signer = match verified_signer {
3294 Some(fp) => fp,
3295 None => {
3296 warn!(%sender_fingerprint, %room_id, "MemberAnnounce arrived unsigned; dropping");
3297 return;
3298 }
3299 };
3300 if signer != sender_fingerprint {
3301 warn!(%signer, %sender_fingerprint, %room_id, "MemberAnnounce signer mismatch; dropping");
3302 return;
3303 }
3304 // Drop announcements from banned fingerprints — they
3305 // can't rejoin until an owner unbans them (Phase B).
3306 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3307 .unwrap_or(false)
3308 {
3309 info!(%sender_fingerprint, %room_id, "dropping MemberAnnounce from banned peer");
3310 return;
3311 }
3312 // Phase E per-room enforcement: if this room is
3313 // verified-only and the joiner isn't globally SAS-
3314 // verified, refuse to add them. The lowest-fp owner
3315 // (deterministic across honest peers) also sends a
3316 // signed `JoinRefused` so the joiner gets an explicit
3317 // message instead of a silent hang.
3318 if repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
3319 && !repo::is_globally_verified(&self.db, &sender_fingerprint).unwrap_or(false)
3320 {
3321 info!(
3322 %sender_fingerprint, %room_id,
3323 "dropping MemberAnnounce: room is verified-only and joiner isn't verified"
3324 );
3325 let owners = repo::list_room_owners(&self.db, room_id).unwrap_or_default();
3326 let lowest_owner = owners.iter().min().cloned();
3327 if lowest_owner.as_deref() == Some(&our_fp) {
3328 let msg = RoomMessage::JoinRefused {
3329 room_id: room_id.to_string(),
3330 target_fingerprint: sender_fingerprint.clone(),
3331 reason: "room requires SAS verification — ask an existing member to verify you".into(),
3332 };
3333 if let Ok(env) = crate::crypto::sign_message(&self.identity, &msg) {
3334 if let Ok(bytes) =
3335 crate::network::protocol::encode_wire_signed(&env)
3336 {
3337 self.network
3338 .publish_room_message(room_id.to_string(), bytes)
3339 .await;
3340 }
3341 }
3342 }
3343 return;
3344 }
3345 let need_inbound = {
3346 let mut rooms = self.active_rooms.lock().unwrap();
3347 let room = match rooms.get_mut(room_id) {
3348 Some(r) => r,
3349 None => return,
3350 };
3351 // huddle 0.7: Direct rooms are 1-1 forever. If a
3352 // third fingerprint announces, drop it locally and
3353 // skip the persist/wrap-session path. This is honest-
3354 // client enforcement — a malicious peer with the
3355 // canonical DM passphrase-equivalent could still
3356 // chat, but they'd never be visible in our sidebar
3357 // or render in the DM pane.
3358 if room.info.kind == RoomKind::Direct
3359 && !room.members.contains(&sender_fingerprint)
3360 && room.members.len() >= 2
3361 {
3362 info!(
3363 %sender_fingerprint, %room_id,
3364 "dropping MemberAnnounce on Direct room: already at 2-member cap"
3365 );
3366 return;
3367 }
3368 let newly_added = room.members.insert(sender_fingerprint.clone());
3369 if newly_added {
3370 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
3371 room_id: room_id.to_string(),
3372 fingerprint: sender_fingerprint.clone(),
3373 });
3374 }
3375 // Persist member with optional display name + pubkey.
3376 // `ed25519_pubkey` is `None` for pre-0.3 peers; the
3377 // upsert COALESCEs so once we learn it we never lose
3378 // it on a later announce that drops the field.
3379 let _ = repo::upsert_room_member(
3380 &self.db,
3381 &StoredRoomMember {
3382 room_id: room_id.to_string(),
3383 peer_id: String::new(), // unknown at this layer
3384 fingerprint: sender_fingerprint.clone(),
3385 last_seen: Some(now_unix()),
3386 verified: false,
3387 ed25519_pubkey: sender_ed25519_pubkey.clone(),
3388 // Role is set on first insert only — the
3389 // upsert ON CONFLICT clause preserves an
3390 // existing 'owner' on re-announce. A genuine
3391 // new fingerprint is a 'member' until an
3392 // OwnerGrant lands.
3393 role: "member".into(),
3394 },
3395 );
3396 if let Some(name) = display_name.as_deref() {
3397 let _ = repo::set_member_display_name(
3398 &self.db,
3399 room_id,
3400 &sender_fingerprint,
3401 Some(name),
3402 );
3403 }
3404 room.info.encrypted && wrapped_session_key.is_some()
3405 };
3406
3407 // huddle 0.7.1: for Direct rooms, the passphrase_key is
3408 // derived from ECDH between our identity key and the
3409 // partner's. The partner's pubkey may arrive in *this*
3410 // MemberAnnounce — so we lazily compute the key now,
3411 // before the unwrap path runs. Idempotent: if we
3412 // already have the key, this is a no-op.
3413 if matches!(
3414 self.active_rooms
3415 .lock()
3416 .unwrap()
3417 .get(room_id)
3418 .map(|r| (r.info.kind, r.passphrase_key.is_none())),
3419 Some((RoomKind::Direct, true))
3420 ) {
3421 if let Some(pubkey_b64) = sender_ed25519_pubkey.as_deref() {
3422 if let Some(key) =
3423 self.derive_dm_key_from_pubkey_b64(room_id, pubkey_b64)
3424 {
3425 let mut rooms = self.active_rooms.lock().unwrap();
3426 if let Some(room) = rooms.get_mut(room_id) {
3427 room.passphrase_key = Some(key);
3428 }
3429 drop(rooms);
3430 // We just got the key — re-broadcast our
3431 // MemberAnnounce so the partner gets our
3432 // wrapped session key. Fire-and-forget;
3433 // failures are logged.
3434 let app = self.clone();
3435 let rid = room_id.to_string();
3436 tokio::spawn(async move {
3437 if let Err(e) = app.broadcast_member_announce(&rid).await {
3438 warn!(%e, "re-broadcast DM announce after key derivation");
3439 }
3440 });
3441 }
3442 }
3443 }
3444
3445 if need_inbound {
3446 let wrapped = wrapped_session_key.unwrap();
3447 let result = {
3448 let mut rooms = self.active_rooms.lock().unwrap();
3449 let room = rooms.get_mut(room_id).unwrap();
3450 let passphrase_key = match &room.passphrase_key {
3451 Some(k) => k,
3452 None => {
3453 warn!("no passphrase key when receiving session key");
3454 return;
3455 }
3456 };
3457 match passphrase::unwrap(&wrapped, passphrase_key) {
3458 Ok(plain) => match String::from_utf8(plain) {
3459 Ok(key_b64) => {
3460 let crypto = room.crypto.as_mut().unwrap();
3461 crypto.add_inbound_session(&sender_fingerprint, &key_b64)
3462 }
3463 Err(e) => Err(HuddleError::Session(format!("utf8: {e}"))),
3464 },
3465 Err(e) => Err(e),
3466 }
3467 };
3468 if let Err(e) = result {
3469 error!(%e, "add inbound session failed");
3470 }
3471 }
3472 }
3473 RoomMessage::SessionKeyRequest {
3474 requester_fingerprint,
3475 } => {
3476 if requester_fingerprint == our_fp {
3477 return;
3478 }
3479 // Re-announce ourselves to share our session key with the new joiner.
3480 if let Err(e) = self.broadcast_member_announce(room_id).await {
3481 warn!(%e, "broadcast member announce on request");
3482 }
3483 }
3484 RoomMessage::Encrypted {
3485 sender_fingerprint,
3486 session_id,
3487 ciphertext_b64,
3488 } => {
3489 if sender_fingerprint == our_fp {
3490 return;
3491 }
3492 // huddle 0.7.11: ban filter on every content-bearing arm.
3493 // Pre-0.7.11 only MemberAnnounce was filtered, so banned
3494 // peers could still post Encrypted/Plain after a kick
3495 // (cosmetically in encrypted rooms post-rotation since
3496 // they have no inbound session, but in unencrypted rooms
3497 // their plaintext rendered freely — see RoomMessage::Plain
3498 // arm below).
3499 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3500 .unwrap_or(false)
3501 {
3502 debug!(%sender_fingerprint, %room_id, "dropping Encrypted from banned peer");
3503 return;
3504 }
3505 let ct_bytes = match base64::Engine::decode(
3506 &base64::engine::general_purpose::STANDARD,
3507 &ciphertext_b64,
3508 ) {
3509 Ok(b) => b,
3510 Err(e) => {
3511 warn!(%e, "bad base64 ciphertext");
3512 return;
3513 }
3514 };
3515 let plaintext = {
3516 let mut rooms = self.active_rooms.lock().unwrap();
3517 let room = match rooms.get_mut(room_id) {
3518 Some(r) => r,
3519 None => return,
3520 };
3521 let crypto = match room.crypto.as_mut() {
3522 Some(c) => c,
3523 None => return,
3524 };
3525 crypto.decrypt(&sender_fingerprint, &session_id, &ct_bytes)
3526 };
3527 match plaintext {
3528 Ok(pt) => {
3529 let body = String::from_utf8_lossy(&pt).to_string();
3530 let sent_at = now_unix();
3531 let _ = repo::insert_room_message(
3532 &self.db,
3533 room_id,
3534 &sender_fingerprint,
3535 "in",
3536 &body,
3537 sent_at,
3538 );
3539 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
3540 self.maybe_emit_mention(room_id, &body);
3541 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
3542 room_id: room_id.to_string(),
3543 sender_fingerprint,
3544 body,
3545 sent_at,
3546 });
3547 }
3548 Err(e) => {
3549 debug!(%e, "decrypt failed (probably missing session key)");
3550 }
3551 }
3552 }
3553 RoomMessage::Plain {
3554 sender_fingerprint,
3555 body,
3556 } => {
3557 if sender_fingerprint == our_fp {
3558 return;
3559 }
3560 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3561 .unwrap_or(false)
3562 {
3563 debug!(%sender_fingerprint, %room_id, "dropping Plain from banned peer");
3564 return;
3565 }
3566 let sent_at = now_unix();
3567 let _ = repo::insert_room_message(
3568 &self.db,
3569 room_id,
3570 &sender_fingerprint,
3571 "in",
3572 &body,
3573 sent_at,
3574 );
3575 let _ = repo::update_room_last_active(&self.db, room_id, sent_at);
3576 self.maybe_emit_mention(room_id, &body);
3577 let _ = self.app_event_tx.send(AppEvent::MessageReceived {
3578 room_id: room_id.to_string(),
3579 sender_fingerprint,
3580 body,
3581 sent_at,
3582 });
3583 }
3584 RoomMessage::Typing { sender_fingerprint } => {
3585 if sender_fingerprint == our_fp {
3586 return;
3587 }
3588 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3589 .unwrap_or(false)
3590 {
3591 return;
3592 }
3593 let expiry = now_unix() + TYPING_TTL_SECS;
3594 let mut rooms = self.active_rooms.lock().unwrap();
3595 if let Some(room) = rooms.get_mut(room_id) {
3596 room.typers.insert(sender_fingerprint, expiry);
3597 }
3598 drop(rooms);
3599 let _ = self.app_event_tx.send(AppEvent::TypingChanged {
3600 room_id: room_id.to_string(),
3601 });
3602 }
3603 RoomMessage::RotateRoomKey {
3604 rotator_fingerprint,
3605 new_salt,
3606 } => {
3607 if rotator_fingerprint == our_fp {
3608 return;
3609 }
3610 // Rotations are self-attested: the signer must be the
3611 // claimed rotator. Unsigned forgeries land in
3612 // `verified_signer = None` and are dropped here, as are
3613 // signed envelopes where the signer fp doesn't match.
3614 let signer = match verified_signer {
3615 Some(fp) => fp,
3616 None => {
3617 warn!(%room_id, "RotateRoomKey arrived unsigned; dropping");
3618 return;
3619 }
3620 };
3621 if signer != rotator_fingerprint {
3622 warn!(
3623 %signer, %rotator_fingerprint, %room_id,
3624 "RotateRoomKey signer mismatch with claimed rotator; dropping"
3625 );
3626 return;
3627 }
3628 let _ = self.app_event_tx.send(AppEvent::RotationRequested {
3629 room_id: room_id.to_string(),
3630 rotator_fingerprint,
3631 new_salt,
3632 });
3633 }
3634 RoomMessage::MemberLeave { sender_fingerprint } => {
3635 if sender_fingerprint == our_fp {
3636 return;
3637 }
3638 // huddle 0.7.11: MemberLeave must arrive inside a signed
3639 // envelope whose signer matches the claimed leaver.
3640 // Pre-0.7.11 plain leaves and forged leaves are dropped.
3641 let signer = match verified_signer {
3642 Some(fp) => fp,
3643 None => {
3644 warn!(%sender_fingerprint, %room_id, "MemberLeave arrived unsigned; dropping");
3645 return;
3646 }
3647 };
3648 if signer != sender_fingerprint {
3649 warn!(%signer, %sender_fingerprint, %room_id, "MemberLeave signer mismatch; dropping");
3650 return;
3651 }
3652 let removed = {
3653 let mut rooms = self.active_rooms.lock().unwrap();
3654 if let Some(room) = rooms.get_mut(room_id) {
3655 room.members.remove(&sender_fingerprint)
3656 } else {
3657 false
3658 }
3659 };
3660 if removed {
3661 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
3662 room_id: room_id.to_string(),
3663 fingerprint: sender_fingerprint,
3664 });
3665 }
3666 }
3667 RoomMessage::FileOffer {
3668 sender_fingerprint,
3669 file_id,
3670 name,
3671 size_bytes,
3672 mime,
3673 chunk_count,
3674 encrypted_meta,
3675 } => {
3676 if sender_fingerprint == our_fp {
3677 return; // ignore our own broadcast
3678 }
3679 // huddle 0.7.11: FileOffer must be signed so peers can't
3680 // spoof attribution. The chunk stream itself stays plain
3681 // (sha256 over the assembly is the integrity gate), but
3682 // who *announced* the file is now bound to the signer.
3683 let signer = match verified_signer {
3684 Some(fp) => fp,
3685 None => {
3686 warn!(%sender_fingerprint, %room_id, %file_id, "FileOffer arrived unsigned; dropping");
3687 return;
3688 }
3689 };
3690 if signer != sender_fingerprint {
3691 warn!(%signer, %sender_fingerprint, %room_id, %file_id, "FileOffer signer mismatch; dropping");
3692 return;
3693 }
3694 // Drop offers from banned peers in the same shape as
3695 // MemberAnnounce — keeps moderation invariant tight.
3696 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3697 .unwrap_or(false)
3698 {
3699 info!(%sender_fingerprint, %room_id, %file_id, "dropping FileOffer from banned peer");
3700 return;
3701 }
3702 self.handle_file_offer(
3703 room_id,
3704 sender_fingerprint,
3705 file_id,
3706 name,
3707 size_bytes,
3708 mime,
3709 chunk_count,
3710 encrypted_meta,
3711 );
3712 }
3713 RoomMessage::FileChunk {
3714 sender_fingerprint,
3715 file_id,
3716 chunk_index,
3717 total_chunks,
3718 data_b64,
3719 } => {
3720 if sender_fingerprint == our_fp {
3721 return;
3722 }
3723 if repo::is_member_banned(&self.db, room_id, &sender_fingerprint)
3724 .unwrap_or(false)
3725 {
3726 return;
3727 }
3728 self.handle_file_chunk(
3729 room_id,
3730 sender_fingerprint,
3731 file_id,
3732 chunk_index,
3733 total_chunks,
3734 data_b64,
3735 );
3736 }
3737 RoomMessage::OwnerGrant {
3738 room_id: announced_room_id,
3739 target_fingerprint,
3740 } => {
3741 // Both: payload room_id must match the topic's room_id
3742 // (no cross-room replay), AND the signer must be a
3743 // current owner of this room. Unsigned forgeries land in
3744 // `verified_signer = None` and are dropped here.
3745 if announced_room_id != room_id {
3746 warn!(payload_room = %announced_room_id, topic_room = %room_id, "OwnerGrant room mismatch");
3747 return;
3748 }
3749 let signer = match verified_signer {
3750 Some(fp) => fp,
3751 None => {
3752 warn!(%room_id, "OwnerGrant arrived unsigned; dropping");
3753 return;
3754 }
3755 };
3756 if !self.is_owner(room_id, &signer) {
3757 warn!(%signer, %room_id, "OwnerGrant signer isn't an owner; dropping");
3758 return;
3759 }
3760 info!(%signer, %target_fingerprint, %room_id, "OwnerGrant applied");
3761 if let Err(e) =
3762 repo::set_member_role(&self.db, room_id, &target_fingerprint, "owner")
3763 {
3764 warn!(%e, "OwnerGrant: set_member_role failed");
3765 }
3766 }
3767 RoomMessage::BanMember {
3768 room_id: announced_room_id,
3769 target_fingerprint,
3770 } => {
3771 if announced_room_id != room_id {
3772 warn!(payload_room = %announced_room_id, topic_room = %room_id, "BanMember room mismatch");
3773 return;
3774 }
3775 let signer = match verified_signer {
3776 Some(fp) => fp,
3777 None => {
3778 warn!(%room_id, "BanMember arrived unsigned; dropping");
3779 return;
3780 }
3781 };
3782 if !self.is_owner(room_id, &signer) {
3783 warn!(%signer, %room_id, "BanMember signer isn't an owner; dropping");
3784 return;
3785 }
3786 if target_fingerprint == our_fp {
3787 // We've been kicked. Locally evict ourselves so the
3788 // TUI tabs close; the kicker's subsequent
3789 // RotateRoomKey will arrive separately and we
3790 // simply won't be able to decrypt the new key,
3791 // matching the "soft kick" semantics.
3792 info!(%room_id, %signer, "we were kicked from this room");
3793 self.active_rooms.lock().unwrap().remove(room_id);
3794 let _ = self.app_event_tx.send(AppEvent::RoomLeft {
3795 room_id: room_id.to_string(),
3796 });
3797 return;
3798 }
3799 info!(%signer, %target_fingerprint, %room_id, "BanMember applied");
3800 if let Err(e) = repo::add_room_ban(
3801 &self.db,
3802 room_id,
3803 &target_fingerprint,
3804 &signer,
3805 "", // signature lives in the envelope, not the row
3806 now_unix(),
3807 ) {
3808 warn!(%e, "BanMember: add_room_ban failed");
3809 }
3810 self.evict_banned_member(room_id, &target_fingerprint);
3811 }
3812 RoomMessage::SasInit {
3813 tx_id,
3814 ephemeral_x25519_pubkey_b64,
3815 target_fingerprint,
3816 } => {
3817 if target_fingerprint != our_fp {
3818 // Not addressed to us — ignore. Phase G is point-
3819 // to-point even though it travels over the room
3820 // topic, so members of the room who aren't the
3821 // target don't need to act.
3822 return;
3823 }
3824 let signer = match verified_signer {
3825 Some(fp) => fp,
3826 None => {
3827 warn!("SasInit arrived unsigned; dropping");
3828 return;
3829 }
3830 };
3831 let their_pub =
3832 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
3833 Ok(pk) => pk,
3834 Err(e) => {
3835 warn!(%e, "SasInit: bad x25519 pubkey");
3836 return;
3837 }
3838 };
3839 let tx_id_bytes = match B64.decode(&tx_id) {
3840 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
3841 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
3842 arr.copy_from_slice(&b);
3843 arr
3844 }
3845 _ => {
3846 warn!(%tx_id, "SasInit: bad tx_id length");
3847 return;
3848 }
3849 };
3850 let (_, our_secret, our_pub) = crate::crypto::sas::new_session();
3851 let sas_code = match crate::crypto::sas::derive_sas_code(
3852 &our_secret,
3853 &their_pub,
3854 &tx_id_bytes,
3855 ) {
3856 Ok(c) => c,
3857 Err(e) => {
3858 warn!(%e, "SasInit: rejecting non-contributory ephemeral; dropping");
3859 return;
3860 }
3861 };
3862 self.sas_flows.lock().unwrap().insert(
3863 tx_id.clone(),
3864 SasFlow {
3865 room_id: room_id.to_string(),
3866 partner_fingerprint: signer.clone(),
3867 our_secret,
3868 sas_code: Some(sas_code.clone()),
3869 our_confirmed: false,
3870 their_confirmed: false,
3871 finalized: false,
3872 },
3873 );
3874 // Respond with our pubkey so the initiator can compute
3875 // the same code.
3876 let response = RoomMessage::SasResponse {
3877 tx_id: tx_id.clone(),
3878 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
3879 };
3880 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
3881 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
3882 self.network
3883 .publish_room_message(room_id.to_string(), bytes)
3884 .await;
3885 }
3886 }
3887 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3888 room_id: room_id.to_string(),
3889 partner_fingerprint: signer,
3890 tx_id,
3891 emoji_labels: sas_code.emoji_labels(),
3892 decimal: sas_code.decimal,
3893 });
3894 }
3895 RoomMessage::SasResponse {
3896 tx_id,
3897 ephemeral_x25519_pubkey_b64,
3898 } => {
3899 let signer = match verified_signer {
3900 Some(fp) => fp,
3901 None => {
3902 warn!("SasResponse arrived unsigned; dropping");
3903 return;
3904 }
3905 };
3906 let their_pub =
3907 match crate::crypto::sas::parse_pubkey(&ephemeral_x25519_pubkey_b64) {
3908 Ok(pk) => pk,
3909 Err(e) => {
3910 warn!(%e, "SasResponse: bad x25519 pubkey");
3911 return;
3912 }
3913 };
3914 let tx_id_bytes = match B64.decode(&tx_id) {
3915 Ok(b) if b.len() == crate::crypto::sas::TX_ID_LEN => {
3916 let mut arr = [0u8; crate::crypto::sas::TX_ID_LEN];
3917 arr.copy_from_slice(&b);
3918 arr
3919 }
3920 _ => return,
3921 };
3922 let emit = {
3923 let mut flows = self.sas_flows.lock().unwrap();
3924 let flow = match flows.get_mut(&tx_id) {
3925 Some(f) => f,
3926 None => {
3927 warn!(%tx_id, "SasResponse for unknown tx_id");
3928 return;
3929 }
3930 };
3931 if flow.partner_fingerprint != signer {
3932 warn!(
3933 expected = %flow.partner_fingerprint, got = %signer,
3934 "SasResponse signer doesn't match flow's partner; dropping"
3935 );
3936 return;
3937 }
3938 let code = match crate::crypto::sas::derive_sas_code(
3939 &flow.our_secret,
3940 &their_pub,
3941 &tx_id_bytes,
3942 ) {
3943 Ok(c) => c,
3944 Err(e) => {
3945 warn!(%e, "SasResponse: rejecting non-contributory ephemeral; dropping");
3946 return;
3947 }
3948 };
3949 flow.sas_code = Some(code.clone());
3950 code
3951 };
3952 let _ = self.app_event_tx.send(AppEvent::SasCodeReady {
3953 room_id: room_id.to_string(),
3954 partner_fingerprint: signer,
3955 tx_id,
3956 emoji_labels: emit.emoji_labels(),
3957 decimal: emit.decimal,
3958 });
3959 }
3960 RoomMessage::CodeJoinRequest {
3961 room_id: announced_room_id,
3962 joiner_x25519_pubkey_b64,
3963 code,
3964 } => {
3965 if announced_room_id != room_id {
3966 return;
3967 }
3968 let joiner_fp = match verified_signer {
3969 Some(fp) => fp,
3970 None => {
3971 warn!("CodeJoinRequest unsigned; dropping");
3972 return;
3973 }
3974 };
3975 // Only owners with an active code are interested in
3976 // responding. Other peers (incl. non-issuing owners)
3977 // simply ignore.
3978 let our_fp = self.identity.fingerprint().to_string();
3979 if !self.is_owner(room_id, &our_fp) {
3980 return;
3981 }
3982 // Match + consume the code. Single use.
3983 let now = now_unix();
3984 let (code_ok, our_session_id, wrap_input) = {
3985 let mut rooms = self.active_rooms.lock().unwrap();
3986 let room = match rooms.get_mut(room_id) {
3987 Some(r) => r,
3988 None => return,
3989 };
3990 if room.passphrase_key.is_none() {
3991 warn!("CodeJoinRequest: no passphrase key locally; can't respond");
3992 return;
3993 }
3994 let original_len = room.issued_codes.len();
3995 room.issued_codes.retain(|(c, exp)| !(c == &code && *exp > now));
3996 let matched = room.issued_codes.len() < original_len;
3997 if !matched {
3998 info!(%joiner_fp, "CodeJoinRequest: code invalid or expired; ignoring");
3999 return;
4000 }
4001 let crypto = room.crypto.as_ref().unwrap();
4002 (
4003 true,
4004 crypto.our_session_id(),
4005 crypto.our_session_key_b64(),
4006 )
4007 };
4008 let _ = code_ok;
4009 // ECDH with the joiner's ephemeral pubkey.
4010 let their_pub = match crate::crypto::sas::parse_pubkey(&joiner_x25519_pubkey_b64) {
4011 Ok(pk) => pk,
4012 Err(e) => {
4013 warn!(%e, "CodeJoinRequest: bad pubkey");
4014 return;
4015 }
4016 };
4017 use x25519_dalek::{PublicKey, StaticSecret};
4018 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
4019 let our_pub = PublicKey::from(&our_secret);
4020 let shared = our_secret.diffie_hellman(&their_pub);
4021 // HKDF the shared secret into a 32-byte wrap key.
4022 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
4023 let mut wrap_key = [0u8; passphrase::KEY_LEN];
4024 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
4025 .expect("32 bytes is within HKDF limits");
4026 // Wrap our session key under the ECDH-derived key,
4027 // reusing the existing AEAD primitives.
4028 let wrapped = match passphrase::wrap(wrap_input.as_bytes(), &wrap_key) {
4029 Ok(w) => w,
4030 Err(e) => {
4031 warn!(%e, "CodeJoinRequest: wrap failed");
4032 return;
4033 }
4034 };
4035 let response = RoomMessage::CodeJoinResponse {
4036 room_id: room_id.to_string(),
4037 target_fingerprint: joiner_fp.clone(),
4038 owner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
4039 owner_session_id: our_session_id,
4040 wrapped_session_key_b64: wrapped,
4041 nonce_b64: String::new(), // nonce is embedded in `wrapped` per passphrase::wrap
4042 };
4043 if let Ok(env) = crate::crypto::sign_message(&self.identity, &response) {
4044 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4045 self.network
4046 .publish_room_message(room_id.to_string(), bytes)
4047 .await;
4048 }
4049 }
4050 info!(%joiner_fp, %room_id, "issued CodeJoinResponse");
4051 }
4052 RoomMessage::CodeJoinResponse {
4053 room_id: announced_room_id,
4054 target_fingerprint,
4055 owner_x25519_pubkey_b64,
4056 owner_session_id,
4057 wrapped_session_key_b64,
4058 nonce_b64: _,
4059 } => {
4060 if announced_room_id != room_id || target_fingerprint != our_fp {
4061 return;
4062 }
4063 let owner_fp = match verified_signer {
4064 Some(fp) => fp,
4065 None => {
4066 warn!("CodeJoinResponse unsigned; dropping");
4067 return;
4068 }
4069 };
4070 let our_secret = match self
4071 .pending_code_secrets
4072 .lock()
4073 .unwrap()
4074 .remove(&(room_id.to_string(), our_fp.clone()))
4075 {
4076 Some(s) => s,
4077 None => {
4078 warn!(%room_id, "CodeJoinResponse with no pending code-join state");
4079 return;
4080 }
4081 };
4082 let owner_pub = match crate::crypto::sas::parse_pubkey(&owner_x25519_pubkey_b64) {
4083 Ok(pk) => pk,
4084 Err(e) => {
4085 warn!(%e, "CodeJoinResponse: bad owner pubkey");
4086 return;
4087 }
4088 };
4089 let shared = our_secret.diffie_hellman(&owner_pub);
4090 let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, shared.as_bytes());
4091 let mut wrap_key = [0u8; passphrase::KEY_LEN];
4092 hk.expand(b"huddle-code-join-v1", &mut wrap_key)
4093 .expect("32 bytes within HKDF limits");
4094 let session_key_bytes =
4095 match passphrase::unwrap(&wrapped_session_key_b64, &wrap_key) {
4096 Ok(b) => b,
4097 Err(e) => {
4098 warn!(%e, "CodeJoinResponse: unwrap failed");
4099 return;
4100 }
4101 };
4102 let session_key_str = match String::from_utf8(session_key_bytes) {
4103 Ok(s) => s,
4104 Err(e) => {
4105 warn!(%e, "CodeJoinResponse: session key wasn't valid utf8");
4106 return;
4107 }
4108 };
4109 // Install as an inbound session keyed by the owner's fp.
4110 let mut rooms = self.active_rooms.lock().unwrap();
4111 if let Some(room) = rooms.get_mut(room_id) {
4112 if let Some(crypto) = room.crypto.as_mut() {
4113 if let Err(e) =
4114 crypto.add_inbound_session(&owner_fp, &session_key_str)
4115 {
4116 warn!(%e, "CodeJoinResponse: add_inbound_session failed");
4117 } else {
4118 info!(%room_id, %owner_fp, %owner_session_id, "code-join completed; can decrypt owner's messages");
4119 room.members.insert(owner_fp.clone());
4120 let _ = self.app_event_tx.send(AppEvent::MemberJoined {
4121 room_id: room_id.to_string(),
4122 fingerprint: owner_fp,
4123 });
4124 }
4125 }
4126 }
4127 }
4128 RoomMessage::JoinRefused {
4129 room_id: announced_room_id,
4130 target_fingerprint,
4131 reason,
4132 } => {
4133 if announced_room_id != room_id || target_fingerprint != our_fp {
4134 return;
4135 }
4136 // Surface the refusal as an Error so the user sees why
4137 // their join didn't take. The Phase 3 modal-queue rule
4138 // means this won't clobber typing in another modal.
4139 let _ = self.app_event_tx.send(AppEvent::Error {
4140 description: format!("join refused: {reason}"),
4141 });
4142 }
4143 RoomMessage::SasConfirm { tx_id, matched } => {
4144 let signer = match verified_signer {
4145 Some(fp) => fp,
4146 None => return,
4147 };
4148 let (room_id_done, partner_fp_done, both_done) = {
4149 let mut flows = self.sas_flows.lock().unwrap();
4150 let flow = match flows.get_mut(&tx_id) {
4151 Some(f) => f,
4152 None => return,
4153 };
4154 if flow.partner_fingerprint != signer {
4155 return;
4156 }
4157 if !matched {
4158 // Partner declined / mismatch — drop the flow.
4159 let _ = flow;
4160 flows.remove(&tx_id);
4161 return;
4162 }
4163 flow.their_confirmed = true;
4164 // huddle 0.7.11: only fire finalize from this arm
4165 // when the flow hasn't already been finalized by
4166 // the local `sas_match` path. The `finalized`
4167 // latch is set inside `finish_sas` (taken under
4168 // this same Mutex), so the two paths can't both
4169 // observe it as `false`.
4170 if flow.our_confirmed && flow.their_confirmed && !flow.finalized {
4171 flow.finalized = true;
4172 (
4173 Some(flow.room_id.clone()),
4174 Some(flow.partner_fingerprint.clone()),
4175 true,
4176 )
4177 } else {
4178 (None, None, false)
4179 }
4180 };
4181 if both_done {
4182 if let (Some(rid), Some(pfp)) = (room_id_done, partner_fp_done) {
4183 if let Err(e) = self.finish_sas(&tx_id, &rid, &pfp).await {
4184 warn!(%e, "finish_sas failed");
4185 }
4186 }
4187 }
4188 }
4189 RoomMessage::ProfileUpdate {
4190 sender_fingerprint,
4191 username,
4192 updated_at,
4193 } => {
4194 // huddle 0.5: username spoof defense. Drop any
4195 // ProfileUpdate that didn't arrive inside a Signed
4196 // envelope, or whose signer doesn't match the claimed
4197 // sender_fingerprint. Without this anyone could pretend
4198 // to be "alice" by stuffing the field.
4199 let signer = match verified_signer {
4200 Some(fp) => fp,
4201 None => {
4202 warn!(
4203 sender = %sender_fingerprint,
4204 "dropping unsigned ProfileUpdate"
4205 );
4206 return;
4207 }
4208 };
4209 if signer != sender_fingerprint {
4210 warn!(
4211 signer = %signer,
4212 claimed = %sender_fingerprint,
4213 "dropping ProfileUpdate with signer != sender"
4214 );
4215 return;
4216 }
4217 if let Err(e) = repo::upsert_peer_profile(
4218 &self.db,
4219 &sender_fingerprint,
4220 username.as_deref(),
4221 updated_at,
4222 ) {
4223 warn!(%e, "upsert_peer_profile failed");
4224 return;
4225 }
4226 let _ = self.app_event_tx.send(AppEvent::PeerProfileUpdated {
4227 fingerprint: sender_fingerprint,
4228 username,
4229 });
4230 }
4231 RoomMessage::ContactRequest {
4232 requester_fingerprint,
4233 display_name,
4234 note,
4235 sender_ed25519_pubkey: _,
4236 } => {
4237 // Only honor a contact request that arrived on OUR own inbox
4238 // room — never one published into a shared room topic.
4239 if room_id != crate::network::protocol::inbox_room_id(&our_fp) {
4240 return;
4241 }
4242 // Must be signed, and the signer must BE the requester — the
4243 // signature is the whole proof of who's asking.
4244 let signer = match verified_signer {
4245 Some(fp) => fp,
4246 None => {
4247 warn!(%requester_fingerprint, "dropping unsigned ContactRequest");
4248 return;
4249 }
4250 };
4251 if signer != requester_fingerprint || requester_fingerprint == our_fp {
4252 return;
4253 }
4254 if repo::is_peer_blocked(&self.db, &requester_fingerprint).unwrap_or(false) {
4255 debug!(%requester_fingerprint, "ignoring ContactRequest from blocked peer");
4256 return;
4257 }
4258 // Mutual case: if this fingerprint is already in our address
4259 // book (we requested them, or we're already connected), treat
4260 // their request as acceptance — open/refresh the DM directly,
4261 // no prompt. This is also how the acceptor's echo-back
4262 // converges the relay path: both sides end up subscribed to
4263 // the canonical DM room, after which the normal MemberAnnounce
4264 // exchange shares session keys.
4265 if self.is_contact(&requester_fingerprint) {
4266 let _ =
4267 repo::delete_pending_contact_request(&self.db, &requester_fingerprint);
4268 if let Err(e) = self.start_direct(&requester_fingerprint).await {
4269 debug!(%e, "ContactRequest mutual: start_direct failed");
4270 }
4271 return;
4272 }
4273 // Fresh inbound request — persist + surface for the user to
4274 // accept or decline from the Contacts pane.
4275 if let Err(e) = repo::upsert_pending_contact_request(
4276 &self.db,
4277 &repo::PendingContactRequest {
4278 fingerprint: requester_fingerprint.clone(),
4279 display_name: display_name.clone(),
4280 note: note.clone(),
4281 received_at: now_unix(),
4282 },
4283 ) {
4284 warn!(%e, "upsert pending contact request failed");
4285 return;
4286 }
4287 let _ = self.app_event_tx.send(AppEvent::ContactRequestReceived {
4288 fingerprint: requester_fingerprint,
4289 display_name,
4290 note,
4291 });
4292 }
4293 }
4294 }
4295
4296 // -------------------------------------------------------------------
4297 // File transfer — public API
4298 // -------------------------------------------------------------------
4299
4300 /// Send a local file to a room. Reads the file, optionally encrypts
4301 /// it for encrypted rooms, chunks it, broadcasts a FileOffer then
4302 /// each FileChunk. Returns the file_id once all chunks are queued.
4303 pub async fn send_file(&self, room_id: &str, path: &Path) -> Result<String> {
4304 let bytes = std::fs::read(path)?;
4305 let name = path
4306 .file_name()
4307 .map(|n| n.to_string_lossy().to_string())
4308 .unwrap_or_else(|| "untitled".into());
4309 let mime = crate::files::guess_mime(&name);
4310 let original_path = path.to_path_buf();
4311
4312 let (room_encrypted, mut maybe_session_id, encrypted_meta_opt, wire_bytes) = {
4313 let mut rooms = self.active_rooms.lock().unwrap();
4314 let room = rooms
4315 .get_mut(room_id)
4316 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4317 // huddle 0.7.11: read-only joiners (code-joined peers) cannot
4318 // send files. Mirrors the check in send_room_message; without
4319 // it, code-joined peers could broadcast FileOffer/FileChunk
4320 // even though existing members ignore their chat messages.
4321 if room.read_only {
4322 return Err(HuddleError::Other(
4323 "this room is read-only — you can't send files".into(),
4324 ));
4325 }
4326 if room.info.encrypted {
4327 let crypto = room
4328 .crypto
4329 .as_mut()
4330 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
4331 let (ciphertext, meta) = file_encryption::encrypt_file(&bytes, crypto)?;
4332 (true, Some(meta.megolm_session_id.clone()), Some(meta), ciphertext)
4333 } else {
4334 (false, None, None, bytes)
4335 }
4336 };
4337 let _ = &mut maybe_session_id; // silence unused warning when non-encrypted
4338
4339 let plan =
4340 self.file_manager
4341 .prepare_outgoing_from_bytes(&name, mime.clone(), wire_bytes)?;
4342 let file_id = plan.file_id.clone();
4343 let total = plan.chunks.len() as u32;
4344 let our_fp = self.identity.fingerprint().to_string();
4345
4346 let attachment = StoredAttachment {
4347 id: 0,
4348 room_id: room_id.to_string(),
4349 message_id: None,
4350 sender_fingerprint: our_fp.clone(),
4351 file_id: file_id.clone(),
4352 name: name.clone(),
4353 mime: mime.clone(),
4354 size_bytes: plan.size_bytes as i64,
4355 status: AttachmentStatus::Ready,
4356 cache_path: Some(self.file_manager.cache_path(&file_id).to_string_lossy().into()),
4357 saved_path: Some(original_path.to_string_lossy().into()),
4358 error: None,
4359 encrypted: room_encrypted,
4360 wrapped_key: encrypted_meta_opt.as_ref().map(|m| m.wrapped_key_b64.clone()),
4361 nonce: encrypted_meta_opt.as_ref().map(|m| m.nonce_b64.clone()),
4362 megolm_session_id: encrypted_meta_opt
4363 .as_ref()
4364 .map(|m| m.megolm_session_id.clone()),
4365 content_hash: encrypted_meta_opt.as_ref().map(|m| m.content_hash.clone()),
4366 created_at: now_unix(),
4367 };
4368 repo::upsert_attachment(&self.db, &attachment)?;
4369 let _ = self.app_event_tx.send(AppEvent::FileOffered {
4370 room_id: room_id.to_string(),
4371 file_id: file_id.clone(),
4372 name: name.clone(),
4373 size_bytes: plan.size_bytes,
4374 sender_fingerprint: our_fp.clone(),
4375 });
4376
4377 // Publish the offer. huddle 0.7.11: FileOffer is now signed so
4378 // peers can't announce a file in someone else's name (attribution
4379 // spoof). FileChunks themselves stay plain — the receiver
4380 // assembles by chunk-index and verifies SHA-256 against
4381 // `file_id`, so spoofed chunks waste bandwidth but can't smuggle
4382 // mismatched bytes through the hash gate.
4383 let offer = RoomMessage::FileOffer {
4384 sender_fingerprint: our_fp.clone(),
4385 file_id: file_id.clone(),
4386 name,
4387 size_bytes: plan.size_bytes,
4388 mime,
4389 chunk_count: total,
4390 encrypted_meta: encrypted_meta_opt,
4391 };
4392 if let Ok(env) = crate::crypto::sign_message(&self.identity, &offer) {
4393 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
4394 self.network
4395 .publish_room_message(room_id.to_string(), bytes)
4396 .await;
4397 }
4398 }
4399
4400 // Stream chunks. Brief pacing so gossipsub doesn't see a thundering
4401 // herd from a single peer.
4402 let net = self.network.clone();
4403 let room = room_id.to_string();
4404 let our = our_fp.clone();
4405 let fid = file_id.clone();
4406 let chunks = plan.chunks.clone();
4407 tokio::spawn(async move {
4408 for (i, data) in chunks.iter().enumerate() {
4409 let msg = RoomMessage::FileChunk {
4410 sender_fingerprint: our.clone(),
4411 file_id: fid.clone(),
4412 chunk_index: i as u32,
4413 total_chunks: total,
4414 data_b64: B64.encode(data),
4415 };
4416 if let Ok(bytes) = encode_wire(&msg) {
4417 net.publish_room_message(room.clone(), bytes).await;
4418 }
4419 tokio::time::sleep(Duration::from_millis(40)).await;
4420 }
4421 });
4422
4423 Ok(file_id)
4424 }
4425
4426 /// Save a completed/ready attachment to the user's Downloads folder.
4427 /// Decrypts encrypted attachments on the way out.
4428 pub async fn save_to_downloads(&self, room_id: &str, file_id: &str) -> Result<PathBuf> {
4429 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
4430 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
4431 if !matches!(
4432 attachment.status,
4433 AttachmentStatus::Ready | AttachmentStatus::Saved
4434 ) {
4435 return Err(HuddleError::Other(format!(
4436 "attachment is not ready (status={})",
4437 attachment.status.as_str()
4438 )));
4439 }
4440 // Our own encrypted attachment: the file_manager cache holds the
4441 // ciphertext and we have no inbound Megolm session keyed by
4442 // ourselves, so it can't be decrypted back. But `saved_path` still
4443 // points at the original plaintext we sent — copy from there.
4444 let plaintext = if attachment.encrypted
4445 && attachment.sender_fingerprint == self.identity.fingerprint()
4446 {
4447 match attachment
4448 .saved_path
4449 .as_deref()
4450 .filter(|p| Path::new(p).exists())
4451 {
4452 Some(src) => std::fs::read(src)?,
4453 None => {
4454 return Err(HuddleError::Other(
4455 "your original file has moved or been deleted — it can't be \
4456 recovered from the encrypted cache"
4457 .into(),
4458 ));
4459 }
4460 }
4461 } else {
4462 let cached = self.file_manager.read_cache(file_id)?;
4463 if attachment.encrypted {
4464 let meta = EncryptedFileMeta {
4465 megolm_session_id: attachment
4466 .megolm_session_id
4467 .clone()
4468 .ok_or_else(|| HuddleError::Other("missing megolm_session_id".into()))?,
4469 wrapped_key_b64: attachment
4470 .wrapped_key
4471 .clone()
4472 .ok_or_else(|| HuddleError::Other("missing wrapped_key".into()))?,
4473 nonce_b64: attachment
4474 .nonce
4475 .clone()
4476 .ok_or_else(|| HuddleError::Other("missing nonce".into()))?,
4477 content_hash: attachment
4478 .content_hash
4479 .clone()
4480 .ok_or_else(|| HuddleError::Other("missing content_hash".into()))?,
4481 };
4482 self.decrypt_attachment(
4483 room_id,
4484 &attachment.sender_fingerprint,
4485 &cached,
4486 &meta,
4487 )?
4488 } else {
4489 cached
4490 }
4491 };
4492 let saved = self.file_manager.write_to_downloads(&attachment.name, &plaintext)?;
4493 repo::update_attachment_paths(
4494 &self.db,
4495 room_id,
4496 file_id,
4497 None,
4498 Some(&saved.to_string_lossy()),
4499 )?;
4500 repo::update_attachment_status(&self.db, room_id, file_id, AttachmentStatus::Saved, None)?;
4501 let _ = self.app_event_tx.send(AppEvent::FileSaved {
4502 file_id: file_id.into(),
4503 path: saved.to_string_lossy().into(),
4504 });
4505 Ok(saved)
4506 }
4507
4508 /// Drop any in-flight chunks and remove the attachment row.
4509 pub async fn cancel_transfer(&self, room_id: &str, file_id: &str) -> Result<()> {
4510 self.file_manager.cancel_incoming(file_id);
4511 repo::update_attachment_status(
4512 &self.db,
4513 room_id,
4514 file_id,
4515 AttachmentStatus::Cancelled,
4516 None,
4517 )?;
4518 Ok(())
4519 }
4520
4521 /// Launch the system's default opener on a saved file.
4522 pub fn open_saved(&self, room_id: &str, file_id: &str) -> Result<()> {
4523 let attachment = repo::get_attachment(&self.db, room_id, file_id)?
4524 .ok_or_else(|| HuddleError::Other("attachment not found".into()))?;
4525 let path = attachment
4526 .saved_path
4527 .ok_or_else(|| HuddleError::Other("not saved yet — press Enter to save first".into()))?;
4528 open_with_system(&path)
4529 }
4530
4531 pub fn list_room_attachments(&self, room_id: &str) -> Result<Vec<StoredAttachment>> {
4532 repo::list_room_attachments(&self.db, room_id)
4533 }
4534
4535 /// Mark a peer's fingerprint as verified in the given room. Used by
4536 /// the `^V` verification modal after the user has compared the
4537 /// fingerprint out-of-band.
4538 pub fn set_member_verified(
4539 &self,
4540 room_id: &str,
4541 fingerprint: &str,
4542 verified: bool,
4543 ) -> Result<()> {
4544 // Make sure there's a member row to flip — peer_id is unknown
4545 // at this layer when the user verifies an out-of-band identity,
4546 // so we use the fingerprint as the canonical identity key with
4547 // an empty peer_id placeholder if none exists.
4548 let members = repo::list_room_members(&self.db, room_id).unwrap_or_default();
4549 if !members.iter().any(|m| m.fingerprint == fingerprint) {
4550 repo::upsert_room_member(
4551 &self.db,
4552 &StoredRoomMember {
4553 room_id: room_id.to_string(),
4554 peer_id: String::new(),
4555 fingerprint: fingerprint.to_string(),
4556 last_seen: Some(now_unix()),
4557 verified,
4558 ed25519_pubkey: None,
4559 role: "member".into(),
4560 },
4561 )?;
4562 }
4563 repo::set_member_verified(&self.db, room_id, fingerprint, verified)
4564 }
4565
4566 pub fn verified_fingerprints(&self, room_id: &str) -> Vec<String> {
4567 repo::list_verified_fingerprints(&self.db, room_id).unwrap_or_default()
4568 }
4569
4570 /// Phase B: is `fingerprint` an owner of `room_id`? Used by the TUI
4571 /// to gate `^K` / `^G` and the kick/grant member-picker actions.
4572 pub fn is_owner(&self, room_id: &str, fingerprint: &str) -> bool {
4573 repo::list_room_owners(&self.db, room_id)
4574 .unwrap_or_default()
4575 .iter()
4576 .any(|fp| fp == fingerprint)
4577 }
4578
4579 pub fn we_are_owner(&self, room_id: &str) -> bool {
4580 self.is_owner(room_id, &self.identity.fingerprint().to_string())
4581 }
4582
4583 /// Phase B: list current owner fingerprints for `room_id` — used to
4584 /// render an owner badge in the member panel.
4585 pub fn room_owners(&self, room_id: &str) -> Vec<String> {
4586 repo::list_room_owners(&self.db, room_id).unwrap_or_default()
4587 }
4588
4589 /// huddle 0.7.6: true iff this session was started with a master
4590 /// passphrase. The TUI uses this to pick the Go Dark gate — passphrase
4591 /// if available (the natural strong secret the user already knows),
4592 /// else the typed `DELETE EVERYTHING` phrase since no-master-passphrase
4593 /// sessions have nothing else to compare against.
4594 pub fn has_master_passphrase(&self) -> bool {
4595 self.session_persist_key != [0u8; 32]
4596 }
4597
4598 /// Phase E: global toggle — when true, inbound dials from
4599 /// unverified fingerprints are auto-rejected without prompting.
4600 pub fn verified_only_inbound(&self) -> bool {
4601 repo::get_setting(&self.db, "verified_only_inbound")
4602 .unwrap_or(None)
4603 .map(|v| v == "1")
4604 .unwrap_or(false)
4605 }
4606
4607 pub fn set_verified_only_inbound(&self, on: bool) -> Result<()> {
4608 repo::set_setting(&self.db, "verified_only_inbound", if on { "1" } else { "0" })
4609 }
4610
4611 /// huddle 0.7.8: persisted LAN-discovery toggle. When true, the next
4612 /// launch starts in `NetworkMode::Mdns` so the device joins LAN mDNS
4613 /// announcements **alongside** the onion relay (both transports run
4614 /// together). When false, the next launch starts relay-only
4615 /// (`NetworkMode::Server`).
4616 ///
4617 /// huddle 0.9.2: default **OFF** (was ON pre-onion-relay) — the
4618 /// relay-only `Server` mode is the 0.8+ baseline, so the toggle is a
4619 /// true opt-in. Restart required to apply (a live `Toggle<Mdns>` flip
4620 /// would require rebuilding the libp2p behaviour).
4621 pub fn mdns_enabled(&self) -> bool {
4622 repo::get_setting(&self.db, "mdns_enabled")
4623 .unwrap_or(None)
4624 .map(|v| v == "1")
4625 .unwrap_or(false)
4626 }
4627
4628 pub fn set_mdns_enabled(&self, on: bool) -> Result<()> {
4629 repo::set_setting(&self.db, "mdns_enabled", if on { "1" } else { "0" })
4630 }
4631
4632 /// huddle 1.1.3: the persisted theme — `"system"` (default; the GUI follows
4633 /// the OS light/dark setting), `"dark"`, or `"light"`. The desktop GUI reads
4634 /// this to pick its egui visuals. huddle 1.1.4: the TUI now honors it too
4635 /// (`"dark"`/`"light"`; `"system"` resolves to Dark there). Unset resolves to
4636 /// `"system"`; installs that already persisted `"dark"`/`"light"` keep them.
4637 pub fn theme(&self) -> String {
4638 repo::get_setting(&self.db, "theme")
4639 .ok()
4640 .flatten()
4641 .filter(|s| !s.trim().is_empty())
4642 .unwrap_or_else(|| "system".to_string())
4643 }
4644
4645 /// huddle 1.1.4: the resolved Tor SOCKS5 proxy address (e.g.
4646 /// `127.0.0.1:9050`). Lets privacy-sensitive clearnet fetches (the
4647 /// opt-in update check) tunnel through Tor rather than leak the IP.
4648 pub fn tor_socks(&self) -> &str {
4649 &self.tor_socks
4650 }
4651
4652 pub fn set_theme(&self, theme: &str) -> Result<()> {
4653 repo::set_setting(&self.db, "theme", theme)
4654 }
4655
4656 /// huddle 1.0: the persisted clearnet relay URL (a `ws://<ip>:<port>/ws`
4657 /// or `wss://host/ws` door onto the relay backend — e.g. a cloudflared
4658 /// tunnel). `None` when unset/blank. This is what the GUI "Set relay" field
4659 /// writes and what [`Self::set_clearnet_relay`] manages; the startup
4660 /// resolution in `start_with_db_and_options` reads it as the lowest-
4661 /// precedence source (CLI → config.toml → this).
4662 pub fn clearnet_relay(&self) -> Option<String> {
4663 repo::get_setting(&self.db, "clearnet_url")
4664 .unwrap_or(None)
4665 .filter(|s| !s.trim().is_empty())
4666 }
4667
4668 /// huddle 1.0: persist (or clear) the clearnet relay URL and bias the
4669 /// transport order so it's tried first.
4670 ///
4671 /// `Some(url)` saves the URL AND pins a clearnet-first door order so the
4672 /// app connects straight to the clearnet relay without paying the onion
4673 /// connect timeout each reconnect cycle (the point of "my VPS, no Tor").
4674 /// `None` (or a blank url) clears both, restoring the default
4675 /// most-private-first order. Takes effect on the next launch — mirrors the
4676 /// mDNS toggle, since the door order is resolved once at startup.
4677 pub fn set_clearnet_relay(&self, url: Option<&str>) -> Result<()> {
4678 match url.map(str::trim).filter(|s| !s.is_empty()) {
4679 Some(u) => {
4680 repo::set_setting(&self.db, "clearnet_url", u)?;
4681 // Clearnet doors first so a no-Tor user connects immediately;
4682 // onion doors stay in the list as fallback.
4683 repo::set_setting(
4684 &self.db,
4685 "transport_order",
4686 "clearnet-wss,clearnet-ws,onion-tor,onion-bridge,onion-arti",
4687 )
4688 }
4689 None => {
4690 repo::set_setting(&self.db, "clearnet_url", "")?;
4691 // Empty → resolution falls back to the default fallback order.
4692 repo::set_setting(&self.db, "transport_order", "")
4693 }
4694 }
4695 }
4696
4697 /// huddle 0.7.8: persisted desktop-notification opt-out. The
4698 /// notifier itself is a local-only `osascript`/`notify-send`
4699 /// process call — toggling this OFF skips the call entirely so
4700 /// nothing reaches the OS notification daemon. Default ON to
4701 /// preserve current behavior.
4702 pub fn notifications_enabled(&self) -> bool {
4703 repo::get_setting(&self.db, "notifications_enabled")
4704 .unwrap_or(None)
4705 .map(|v| v == "1")
4706 .unwrap_or(true)
4707 }
4708
4709 pub fn set_notifications_enabled(&self, on: bool) -> Result<()> {
4710 repo::set_setting(
4711 &self.db,
4712 "notifications_enabled",
4713 if on { "1" } else { "0" },
4714 )
4715 }
4716
4717 /// huddle 0.7.8: stable 12-hex Safety Code derived from our Ed25519
4718 /// pubkey. Display-only; used as a quick visual fingerprint match in
4719 /// Profile / Account. SAS-via-emoji remains the actual verification
4720 /// primitive.
4721 pub fn safety_code(&self) -> String {
4722 crate::identity::safety_code(&self.identity.public_bytes())
4723 }
4724
4725 /// Phase E: per-room verified-only-join. When true, the host (and
4726 /// every honest existing member) drops MemberAnnounce from joiners
4727 /// who aren't globally SAS-verified, and the lowest-fp owner sends
4728 /// back a signed `JoinRefused` so the joiner sees an explanation.
4729 pub fn room_verified_only(&self, room_id: &str) -> bool {
4730 repo::get_room_verified_only(&self.db, room_id).unwrap_or(false)
4731 }
4732
4733 pub fn set_room_verified_only(&self, room_id: &str, on: bool) -> Result<()> {
4734 repo::set_room_verified_only(&self.db, room_id, on)
4735 }
4736
4737 /// Phase H: first-launch onboarding flag.
4738 pub fn onboarding_seen(&self) -> bool {
4739 repo::is_onboarding_seen(&self.db).unwrap_or(true)
4740 }
4741
4742 pub fn mark_onboarding_seen(&self) -> Result<()> {
4743 repo::mark_onboarding_seen(&self.db)
4744 }
4745
4746 /// huddle 0.6: version string of huddle the user last finished
4747 /// onboarding for. Compared against `env!("CARGO_PKG_VERSION")` at
4748 /// startup so a version bump re-fires the "what's new" card.
4749 pub fn last_seen_onboarding_version(&self) -> Option<String> {
4750 repo::get_last_seen_onboarding_version(&self.db).unwrap_or(None)
4751 }
4752
4753 pub fn set_last_seen_onboarding_version(&self, version: &str) -> Result<()> {
4754 repo::set_last_seen_onboarding_version(&self.db, version)
4755 }
4756
4757 /// huddle 0.6: opt-in flag for the crates.io update check.
4758 /// `None` ⇒ the user hasn't been asked yet.
4759 pub fn update_check_enabled(&self) -> Option<bool> {
4760 repo::get_update_check_enabled(&self.db).unwrap_or(None)
4761 }
4762
4763 pub fn set_update_check_enabled(&self, enabled: bool) -> Result<()> {
4764 repo::set_update_check_enabled(&self.db, enabled)
4765 }
4766
4767 /// huddle 0.6: cache anchor for the once-per-24h crates.io poll.
4768 /// Returns 0 if nothing has been recorded yet.
4769 pub fn last_update_check_at(&self) -> i64 {
4770 repo::get_setting(&self.db, "last_update_check_at")
4771 .ok()
4772 .flatten()
4773 .and_then(|s| s.parse().ok())
4774 .unwrap_or(0)
4775 }
4776
4777 pub fn set_last_update_check_at(&self, ts: i64) -> Result<()> {
4778 repo::set_setting(&self.db, "last_update_check_at", &ts.to_string())
4779 }
4780
4781 /// huddle 0.6: the most recent `max_stable_version` we saw on
4782 /// crates.io. Persisted so a re-launch within the 24h window
4783 /// can render the banner without re-fetching.
4784 pub fn last_known_remote_version(&self) -> Option<String> {
4785 repo::get_setting(&self.db, "last_known_remote_version")
4786 .ok()
4787 .flatten()
4788 }
4789
4790 pub fn set_last_known_remote_version(&self, v: &str) -> Result<()> {
4791 repo::set_setting(&self.db, "last_known_remote_version", v)
4792 }
4793
4794 /// Phase B: promote `target_fingerprint` to owner. Builds a signed
4795 /// `OwnerGrant`, broadcasts it, and applies it locally. Returns an
4796 /// error if we ourselves aren't an owner — only owners can grant.
4797 pub async fn grant_owner(&self, room_id: &str, target_fingerprint: &str) -> Result<()> {
4798 let our_fp = self.identity.fingerprint().to_string();
4799 if !self.is_owner(room_id, &our_fp) {
4800 return Err(HuddleError::Other(
4801 "only an owner can grant owner".into(),
4802 ));
4803 }
4804 let msg = RoomMessage::OwnerGrant {
4805 room_id: room_id.to_string(),
4806 target_fingerprint: target_fingerprint.to_string(),
4807 };
4808 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4809 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4810 self.network
4811 .publish_room_message(room_id.to_string(), bytes)
4812 .await;
4813 // Apply locally too — peers will converge on the next announce.
4814 repo::set_member_role(&self.db, room_id, target_fingerprint, "owner")?;
4815 Ok(())
4816 }
4817
4818 /// Phase B: kick `target_fingerprint` from `room_id`. Broadcasts a
4819 /// signed `BanMember`, records the ban locally, then immediately
4820 /// rotates the room key under a freshly-generated passphrase. Returns
4821 /// the new passphrase so the caller can show it to the owner for
4822 /// out-of-band sharing with remaining members.
4823 ///
4824 /// The rotation is the cryptographic enforcement: a banned peer can
4825 /// still subscribe to the gossipsub topic and see the ciphertext,
4826 /// but they can't unwrap the new session key without the new
4827 /// passphrase, so they can't decrypt anything sent after the kick.
4828 pub async fn kick_member(
4829 &self,
4830 room_id: &str,
4831 target_fingerprint: &str,
4832 ) -> Result<String> {
4833 let our_fp = self.identity.fingerprint().to_string();
4834 if !self.is_owner(room_id, &our_fp) {
4835 return Err(HuddleError::Other("only an owner can kick".into()));
4836 }
4837 if target_fingerprint == our_fp {
4838 return Err(HuddleError::Other("can't kick yourself".into()));
4839 }
4840 let info = self
4841 .active_rooms
4842 .lock()
4843 .unwrap()
4844 .get(room_id)
4845 .map(|r| r.info.clone())
4846 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4847 if !info.encrypted {
4848 // Without a key to rotate, a "kick" is purely advisory —
4849 // ban only. Honest clients drop their messages, but anyone
4850 // can still read the room. Honest in v1; documented.
4851 let msg = RoomMessage::BanMember {
4852 room_id: room_id.to_string(),
4853 target_fingerprint: target_fingerprint.to_string(),
4854 };
4855 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4856 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4857 self.network
4858 .publish_room_message(room_id.to_string(), bytes)
4859 .await;
4860 repo::add_room_ban(
4861 &self.db,
4862 room_id,
4863 target_fingerprint,
4864 &our_fp,
4865 &env.signature_b64,
4866 now_unix(),
4867 )?;
4868 self.evict_banned_member(room_id, target_fingerprint);
4869 return Ok(String::new());
4870 }
4871 // Encrypted room — full kick path.
4872 let new_passphrase = generate_join_passphrase();
4873 let msg = RoomMessage::BanMember {
4874 room_id: room_id.to_string(),
4875 target_fingerprint: target_fingerprint.to_string(),
4876 };
4877 let env = crate::crypto::sign_message(&self.identity, &msg)?;
4878 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
4879 self.network
4880 .publish_room_message(room_id.to_string(), bytes)
4881 .await;
4882 repo::add_room_ban(
4883 &self.db,
4884 room_id,
4885 target_fingerprint,
4886 &our_fp,
4887 &env.signature_b64,
4888 now_unix(),
4889 )?;
4890 self.evict_banned_member(room_id, target_fingerprint);
4891 // Reuse the existing rotation flow so all the existing salt /
4892 // session / persistence logic stays in one place.
4893 self.rotate_room(room_id, &new_passphrase).await?;
4894 Ok(new_passphrase)
4895 }
4896
4897 /// Phase F: generate an 8-char alphanumeric join code for `room_id`,
4898 /// good for 10 minutes. Stored in memory only on the issuing owner's
4899 /// machine — a single use clears it. Caller is responsible for
4900 /// sharing the code OOB with the prospective joiner.
4901 ///
4902 /// Owner-only. Errors if `room_id` isn't active or we're not an owner.
4903 pub fn generate_join_code(&self, room_id: &str) -> Result<String> {
4904 let our_fp = self.identity.fingerprint().to_string();
4905 if !self.is_owner(room_id, &our_fp) {
4906 return Err(HuddleError::Other(
4907 "only an owner can issue join codes".into(),
4908 ));
4909 }
4910 let code = generate_alphanumeric_code(8);
4911 let expires_at = now_unix() + 10 * 60;
4912 let mut rooms = self.active_rooms.lock().unwrap();
4913 let room = rooms
4914 .get_mut(room_id)
4915 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
4916 // Prune expired entries while we're here so the list doesn't grow.
4917 let now = now_unix();
4918 room.issued_codes.retain(|(_, exp)| *exp > now);
4919 room.issued_codes.push((code.clone(), expires_at));
4920 Ok(code)
4921 }
4922
4923 /// Phase F: join `room_id` using a short-lived code instead of the
4924 /// passphrase. Generates an ephemeral X25519 keypair, broadcasts a
4925 /// signed `CodeJoinRequest`, and waits for the owner's
4926 /// `CodeJoinResponse`. The receive arm builds an `ActiveRoom`
4927 /// flagged read-only (no passphrase = can't share our outbound
4928 /// session key with others).
4929 pub async fn join_room_with_code(
4930 &self,
4931 room_id: &str,
4932 code: &str,
4933 ) -> Result<()> {
4934 // Resolve discovered metadata so we know name/encrypted/etc.
4935 let info = {
4936 let d = self.discovered_rooms.lock().unwrap().get(room_id).cloned();
4937 match d {
4938 Some(d) => StoredRoom {
4939 id: room_id.to_string(),
4940 name: d.name,
4941 creator_fingerprint: d.creator_fingerprint,
4942 encrypted: d.encrypted,
4943 passphrase_salt: None, // unused on code-join path
4944 created_at: now_unix(),
4945 last_active: Some(now_unix()),
4946 // huddle 0.7: code-join is groups-only by design — DMs
4947 // are 1-1 and don't use the code flow.
4948 kind: d.kind,
4949 },
4950 None => {
4951 return Err(HuddleError::Other(format!(
4952 "room {room_id} not visible — wait for an announcement"
4953 )))
4954 }
4955 }
4956 };
4957 if !info.encrypted {
4958 return Err(HuddleError::Other(
4959 "code-join only applies to encrypted rooms".into(),
4960 ));
4961 }
4962 let our_fp = self.identity.fingerprint().to_string();
4963 // Generate ephemeral X25519 keypair; remember the secret so the
4964 // CodeJoinResponse receive arm can complete ECDH on this peer.
4965 use x25519_dalek::{PublicKey, StaticSecret};
4966 let our_secret = StaticSecret::random_from_rng(rand::thread_rng());
4967 let our_pub = PublicKey::from(&our_secret);
4968 // Stash the secret keyed by (room_id, our_fp); the response
4969 // handler removes the matching entry when a response targeted
4970 // at us arrives. The composite key means a second joiner can
4971 // be in flight in the same room without overwriting our state.
4972 let key = (room_id.to_string(), our_fp.clone());
4973 self.pending_code_secrets
4974 .lock()
4975 .unwrap()
4976 .insert(key.clone(), our_secret);
4977 // Code-join timeout: if no response in 30s, the entry will
4978 // still be in the map (the response handler removes it on
4979 // success). Surface a `CodeJoinTimedOut` to the TUI so the
4980 // user isn't stuck staring at an empty room expecting traffic.
4981 let map = self.pending_code_secrets.clone();
4982 let tx = self.app_event_tx.clone();
4983 let timeout_room = room_id.to_string();
4984 tokio::spawn(async move {
4985 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
4986 let still_pending = map.lock().unwrap().remove(&key).is_some();
4987 if still_pending {
4988 let _ = tx.send(AppEvent::CodeJoinTimedOut {
4989 room_id: timeout_room,
4990 reason: "no response from owner — code may be wrong or expired".into(),
4991 });
4992 }
4993 });
4994 // Persist the rooms row BEFORE constructing RoomCrypto, whose
4995 // `persist_outbound()` writes a `room_megolm_sessions` row with
4996 // a FK to `rooms(id)`. Without this, the FK fires and the
4997 // join aborts. The salt is left None for now — we don't have
4998 // the passphrase and the announcing peer's salt is cached in
4999 // ROOM_SALT_CACHE for whenever we get re-onboarded.
5000 repo::insert_room(&self.db, &info)?;
5001 // Create a placeholder ActiveRoom with no crypto yet; we'll
5002 // fill in the inbound session in the response handler.
5003 self.active_rooms.lock().unwrap().insert(
5004 room_id.to_string(),
5005 ActiveRoom {
5006 info: info.clone(),
5007 crypto: Some(RoomCrypto::new_for_room(
5008 self.db.clone(),
5009 room_id.to_string(),
5010 our_fp.clone(),
5011 self.session_persist_key,
5012 )?),
5013 passphrase_key: None,
5014 members: {
5015 let mut s = HashSet::new();
5016 s.insert(our_fp.clone());
5017 s
5018 },
5019 typers: HashMap::new(),
5020 read_only: true,
5021 issued_codes: Vec::new(),
5022 },
5023 );
5024 self.network.subscribe_room(room_id.to_string()).await;
5025 // Broadcast the request.
5026 let req = RoomMessage::CodeJoinRequest {
5027 room_id: room_id.to_string(),
5028 joiner_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
5029 code: code.to_string(),
5030 };
5031 let env = crate::crypto::sign_message(&self.identity, &req)?;
5032 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5033 self.network
5034 .publish_room_message(room_id.to_string(), bytes)
5035 .await;
5036 // Emit RoomJoined so the TUI opens the tab. Subsequent ability
5037 // to read messages depends on receiving the owner's response.
5038 let _ = self.app_event_tx.send(AppEvent::RoomJoined {
5039 room_id: room_id.to_string(),
5040 });
5041 Ok(())
5042 }
5043
5044 /// Phase G: start an SAS verification with `target_fingerprint` in
5045 /// `room_id`. Returns the tx_id so the caller can correlate
5046 /// subsequent events. The full flow is asynchronous — the partner
5047 /// must accept on their end, both compute the ECDH-derived SAS
5048 /// code, OOB-compare it, and each press Match.
5049 pub async fn sas_start(&self, room_id: &str, target_fingerprint: &str) -> Result<String> {
5050 let (tx_id_bytes, our_secret, our_pub) = crate::crypto::sas::new_session();
5051 let tx_id = B64.encode(tx_id_bytes);
5052 let msg = RoomMessage::SasInit {
5053 tx_id: tx_id.clone(),
5054 ephemeral_x25519_pubkey_b64: B64.encode(our_pub.as_bytes()),
5055 target_fingerprint: target_fingerprint.to_string(),
5056 };
5057 let env = crate::crypto::sign_message(&self.identity, &msg)?;
5058 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5059 self.sas_flows.lock().unwrap().insert(
5060 tx_id.clone(),
5061 SasFlow {
5062 room_id: room_id.to_string(),
5063 partner_fingerprint: target_fingerprint.to_string(),
5064 our_secret,
5065 sas_code: None,
5066 our_confirmed: false,
5067 their_confirmed: false,
5068 finalized: false,
5069 },
5070 );
5071 self.network
5072 .publish_room_message(room_id.to_string(), bytes)
5073 .await;
5074 Ok(tx_id)
5075 }
5076
5077 /// Phase G: user pressed Match on the SAS code modal — broadcast our
5078 /// signed `SasConfirm{matched: true}`. If the partner has already
5079 /// matched, this completes verification on both sides.
5080 pub async fn sas_match(&self, tx_id: &str) -> Result<()> {
5081 let (room_id, partner_fp, both_done) = {
5082 let mut flows = self.sas_flows.lock().unwrap();
5083 let flow = flows
5084 .get_mut(tx_id)
5085 .ok_or_else(|| HuddleError::Other("unknown SAS tx_id".into()))?;
5086 flow.our_confirmed = true;
5087 // huddle 0.7.11: latch finalize so the inbound SasConfirm
5088 // handler won't fire `finish_sas` a second time. See
5089 // SasConfirm arm for the symmetric guard.
5090 let do_finish = flow.our_confirmed && flow.their_confirmed && !flow.finalized;
5091 if do_finish {
5092 flow.finalized = true;
5093 }
5094 (
5095 flow.room_id.clone(),
5096 flow.partner_fingerprint.clone(),
5097 do_finish,
5098 )
5099 };
5100 let msg = RoomMessage::SasConfirm {
5101 tx_id: tx_id.to_string(),
5102 matched: true,
5103 };
5104 let env = crate::crypto::sign_message(&self.identity, &msg)?;
5105 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5106 self.network
5107 .publish_room_message(room_id.clone(), bytes)
5108 .await;
5109 if both_done {
5110 self.finish_sas(tx_id, &room_id, &partner_fp).await?;
5111 }
5112 Ok(())
5113 }
5114
5115 /// Phase G: cancel an in-flight SAS — drop our local state. Doesn't
5116 /// broadcast a "matched=false" notice in v1 (partner's flow stays
5117 /// dangling; they can cancel their side too). Quiet teardown.
5118 pub fn sas_cancel(&self, tx_id: &str) {
5119 self.sas_flows.lock().unwrap().remove(tx_id);
5120 }
5121
5122 /// Phase G internal: both sides have confirmed — flip the partner's
5123 /// fingerprint to verified (per-room AND global) and clean up.
5124 async fn finish_sas(
5125 &self,
5126 tx_id: &str,
5127 room_id: &str,
5128 partner_fingerprint: &str,
5129 ) -> Result<()> {
5130 repo::set_member_verified(&self.db, room_id, partner_fingerprint, true)?;
5131 repo::add_verified_peer(&self.db, partner_fingerprint, now_unix())?;
5132 self.sas_flows.lock().unwrap().remove(tx_id);
5133 let _ = self.app_event_tx.send(AppEvent::SasVerified {
5134 room_id: room_id.to_string(),
5135 partner_fingerprint: partner_fingerprint.to_string(),
5136 });
5137 Ok(())
5138 }
5139
5140 /// Phase B internal: drop a banned member's in-memory presence in a
5141 /// room. Persistent ban already went to `room_bans`. Called from
5142 /// `kick_member` (locally banning ourselves) and from the
5143 /// `RoomMessage::BanMember` receive arm (peer-initiated ban).
5144 fn evict_banned_member(&self, room_id: &str, fingerprint: &str) {
5145 if let Some(room) = self.active_rooms.lock().unwrap().get_mut(room_id) {
5146 room.members.remove(fingerprint);
5147 }
5148 let _ = self.app_event_tx.send(AppEvent::MemberLeft {
5149 room_id: room_id.to_string(),
5150 fingerprint: fingerprint.to_string(),
5151 });
5152 }
5153
5154 pub fn display_name(&self) -> Option<String> {
5155 repo::get_display_name(&self.db).unwrap_or(None)
5156 }
5157
5158 pub fn set_display_name(&self, name: Option<&str>) -> Result<()> {
5159 repo::set_display_name(&self.db, name)
5160 }
5161
5162 /// huddle 0.5: set the local user's self-declared username (or clear
5163 /// it with None) and broadcast a signed `ProfileUpdate` to every
5164 /// joined room. Receivers cache the latest per-fingerprint username
5165 /// in `peer_profiles`; unsigned envelopes are dropped at the receive
5166 /// arm so the username can't be spoofed.
5167 pub async fn set_username(&self, name: Option<&str>) -> Result<()> {
5168 repo::set_display_name(&self.db, name)?;
5169 let msg = RoomMessage::ProfileUpdate {
5170 sender_fingerprint: self.identity.fingerprint().to_string(),
5171 username: name.map(|s| s.to_string()),
5172 updated_at: now_unix_ms(),
5173 };
5174 let env = crate::crypto::sign_message(&self.identity, &msg)?;
5175 let bytes = crate::network::protocol::encode_wire_signed(&env)?;
5176 let rooms: Vec<String> = self.active_rooms.lock().unwrap().keys().cloned().collect();
5177 for room_id in rooms {
5178 self.network
5179 .publish_room_message(room_id, bytes.clone())
5180 .await;
5181 }
5182 Ok(())
5183 }
5184
5185 /// huddle 0.5: cached username for a peer (any peer we've ever
5186 /// received a signed `ProfileUpdate` from), or None if unknown or
5187 /// the peer cleared their username. Callers render `[anonymous]` on
5188 /// None.
5189 pub fn lookup_username(&self, fingerprint: &str) -> Option<String> {
5190 repo::get_peer_username(&self.db, fingerprint).unwrap_or(None)
5191 }
5192
5193 /// Look up the display name we've seen for a peer. Forwards to
5194 /// `lookup_username` (the new signed-source-of-truth) so existing
5195 /// call sites get the authenticated value without churn.
5196 pub fn lookup_member_display_name(&self, fingerprint: &str) -> Option<String> {
5197 self.lookup_username(fingerprint)
5198 }
5199
5200 /// huddle 0.7.12: reverse of `lookup_username` — every fingerprint
5201 /// that has broadcast `username` via a signed `ProfileUpdate`.
5202 /// Usernames aren't unique, so callers must handle 0 / 1 / many.
5203 /// Backs the Compose-DM resolver so typing a contact's name opens a
5204 /// DM over the existing mesh instead of falling through to a fresh
5205 /// dial (matching the resolution `dial_by_id_or_username` already
5206 /// does for the add-friend flow).
5207 pub fn peers_with_username(&self, username: &str) -> Vec<String> {
5208 repo::find_peers_by_username(&self.db, username).unwrap_or_default()
5209 }
5210
5211 pub fn is_room_muted(&self, room_id: &str) -> bool {
5212 repo::is_room_muted(&self.db, room_id).unwrap_or(false)
5213 }
5214
5215 /// Phase B: list the fingerprints currently banned from a room
5216 /// (newest first). Backs the `^B` in-room view; intended for
5217 /// owners but the read itself is harmless and we let callers
5218 /// gate via `we_are_owner` if they want owner-only display.
5219 pub fn list_room_bans(&self, room_id: &str) -> Vec<String> {
5220 repo::list_room_bans(&self.db, room_id).unwrap_or_default()
5221 }
5222
5223 /// Phase A: list every globally-blocked peer (one fingerprint per
5224 /// row). Surfaced in the Settings modal alongside a clear-all
5225 /// action that calls `unblock_peer` in a loop.
5226 /// huddle 0.7: every globally SAS-verified peer. Surfaced in the
5227 /// People pane's "Verified" sub-list.
5228 pub fn list_verified_peers(&self) -> Vec<String> {
5229 repo::list_verified_peers(&self.db).unwrap_or_default()
5230 }
5231
5232 pub fn list_blocked_peers(&self) -> Vec<String> {
5233 repo::list_blocked_peers(&self.db).unwrap_or_default()
5234 }
5235
5236 /// Phase A: remove `fingerprint` from the persistent blocklist. The
5237 /// peer will no longer be auto-rejected on connection; they fall
5238 /// back to the regular inbound-dial accept/reject prompt.
5239 pub fn unblock_peer(&self, fingerprint: &str) -> Result<()> {
5240 repo::unblock_peer(&self.db, fingerprint)
5241 }
5242
5243 /// huddle 0.7: add `fingerprint` to the persistent blocklist. Used
5244 /// by the People pane's per-row "block" action. Subsequent inbound
5245 /// dials from this fingerprint are auto-rejected without prompting.
5246 pub fn block_peer(&self, fingerprint: &str) -> Result<()> {
5247 repo::block_peer(&self.db, fingerprint, now_unix())
5248 }
5249
5250 /// Phase F: rooms entered via a join code don't have the passphrase
5251 /// in memory, so the joining peer can't wrap their own outbound
5252 /// session key for newer members — they can read and send, they
5253 /// just can't onboard others. The TUI renders a `(read-only)`
5254 /// badge in the room tab so the user understands.
5255 pub fn is_room_read_only(&self, room_id: &str) -> bool {
5256 self.active_rooms
5257 .lock()
5258 .unwrap()
5259 .get(room_id)
5260 .map(|r| r.read_only)
5261 .unwrap_or(false)
5262 }
5263
5264 pub fn set_room_muted(&self, room_id: &str, muted: bool) -> Result<()> {
5265 repo::set_room_muted(&self.db, room_id, muted)
5266 }
5267
5268 /// Broadcast a "I'm typing" pulse to the given room. Caller is
5269 /// responsible for debouncing (don't fire more than every ~500ms).
5270 pub async fn broadcast_typing(&self, room_id: &str) {
5271 if !self.active_rooms.lock().unwrap().contains_key(room_id) {
5272 return;
5273 }
5274 let msg = RoomMessage::Typing {
5275 sender_fingerprint: self.identity.fingerprint().to_string(),
5276 };
5277 if let Ok(bytes) = encode_wire(&msg) {
5278 self.network
5279 .publish_room_message(room_id.to_string(), bytes)
5280 .await;
5281 }
5282 }
5283
5284 /// Returns the fingerprints of peers currently typing in `room_id`,
5285 /// pruning entries past their TTL.
5286 pub fn typers_in_room(&self, room_id: &str) -> Vec<String> {
5287 let now = now_unix();
5288 let mut rooms = self.active_rooms.lock().unwrap();
5289 let room = match rooms.get_mut(room_id) {
5290 Some(r) => r,
5291 None => return Vec::new(),
5292 };
5293 room.typers.retain(|_, exp| *exp > now);
5294 let mut v: Vec<String> = room.typers.keys().cloned().collect();
5295 v.sort();
5296 v
5297 }
5298
5299 // -------------------------------------------------------------------
5300 // Room key rotation
5301 // -------------------------------------------------------------------
5302
5303 /// Rotate this room's outbound Megolm session under a fresh
5304 /// passphrase. Broadcasts `RotateRoomKey` (so other members know to
5305 /// expect a new passphrase) and a fresh `MemberAnnounce` with the
5306 /// new wrapped session key. Old inbound sessions stay in storage
5307 /// for decrypting historic messages.
5308 pub async fn rotate_room(&self, room_id: &str, new_passphrase: &str) -> Result<()> {
5309 if new_passphrase.is_empty() {
5310 return Err(HuddleError::Other("new passphrase is empty".into()));
5311 }
5312 let new_salt = passphrase::random_salt();
5313 let new_key = passphrase::derive_key(new_passphrase, &new_salt)?;
5314
5315 let info = {
5316 let mut rooms = self.active_rooms.lock().unwrap();
5317 let room = rooms
5318 .get_mut(room_id)
5319 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
5320 if !room.info.encrypted {
5321 return Err(HuddleError::Other(
5322 "rotation only applies to encrypted rooms".into(),
5323 ));
5324 }
5325 // Generate a fresh outbound Megolm session for this member.
5326 let new_crypto = RoomCrypto::new_for_room(
5327 self.db.clone(),
5328 room_id.to_string(),
5329 self.identity.fingerprint().to_string(),
5330 self.session_persist_key,
5331 )?;
5332 room.crypto = Some(new_crypto);
5333 room.passphrase_key = Some(new_key);
5334 room.info.passphrase_salt = Some(new_salt.to_vec());
5335 room.info.clone()
5336 };
5337
5338 // Broadcast before persisting: peers learn about the rotation even
5339 // if we crash before the DB write lands, and our own restore path
5340 // can recover from the persisted Megolm session plus the announced
5341 // salt. Persisting first would risk a DB row that's ahead of what
5342 // any peer knows.
5343 let rot = RoomMessage::RotateRoomKey {
5344 rotator_fingerprint: self.identity.fingerprint().to_string(),
5345 new_salt: new_salt.to_vec(),
5346 };
5347 // Signed: rotations are self-attested, so peers can prove the
5348 // claimed `rotator_fingerprint` really came from that identity.
5349 // An unsigned rotation is rejected on the receive side.
5350 if let Ok(env) = crate::crypto::sign_message(&self.identity, &rot) {
5351 if let Ok(bytes) = crate::network::protocol::encode_wire_signed(&env) {
5352 self.network
5353 .publish_room_message(room_id.to_string(), bytes)
5354 .await;
5355 }
5356 }
5357 // Re-announce ourselves with the new wrapped session key.
5358 if let Err(e) = self.broadcast_member_announce(room_id).await {
5359 warn!(%e, "rotate: broadcast announce failed");
5360 }
5361
5362 // Now persist the new salt on the stored row.
5363 repo::insert_room(&self.db, &info)?;
5364 Ok(())
5365 }
5366
5367 /// Used by the TUI when another member rotates a room we're in.
5368 /// Derives the new key, updates our local state, and re-announces
5369 /// so the rotator can share their fresh outbound session with us.
5370 pub async fn accept_rotation(
5371 &self,
5372 room_id: &str,
5373 new_salt: &[u8],
5374 new_passphrase: &str,
5375 ) -> Result<()> {
5376 let new_key = passphrase::derive_key(new_passphrase, new_salt)?;
5377 let info = {
5378 let mut rooms = self.active_rooms.lock().unwrap();
5379 let room = rooms
5380 .get_mut(room_id)
5381 .ok_or_else(|| HuddleError::Other(format!("not in room {room_id}")))?;
5382 room.passphrase_key = Some(new_key);
5383 room.info.passphrase_salt = Some(new_salt.to_vec());
5384 room.info.clone()
5385 };
5386 // Ask the rotator (and anyone) to re-share their session key
5387 // before persisting, so a crash before the DB write still leaves
5388 // peers aware we've moved to the new salt.
5389 let req = RoomMessage::SessionKeyRequest {
5390 requester_fingerprint: self.identity.fingerprint().to_string(),
5391 };
5392 if let Ok(bytes) = encode_wire(&req) {
5393 self.network
5394 .publish_room_message(room_id.to_string(), bytes)
5395 .await;
5396 }
5397 repo::insert_room(&self.db, &info)?;
5398 Ok(())
5399 }
5400
5401 // -------------------------------------------------------------------
5402 // File transfer — internal handlers
5403 // -------------------------------------------------------------------
5404
5405 #[allow(clippy::too_many_arguments)]
5406 fn handle_file_offer(
5407 &self,
5408 room_id: &str,
5409 sender_fingerprint: String,
5410 file_id: String,
5411 name: String,
5412 size_bytes: u64,
5413 mime: Option<String>,
5414 _chunk_count: u32,
5415 encrypted_meta: Option<EncryptedFileMeta>,
5416 ) {
5417 let encrypted = encrypted_meta.is_some();
5418 let attachment = StoredAttachment {
5419 id: 0,
5420 room_id: room_id.to_string(),
5421 message_id: None,
5422 sender_fingerprint: sender_fingerprint.clone(),
5423 file_id: file_id.clone(),
5424 name: name.clone(),
5425 mime,
5426 size_bytes: size_bytes as i64,
5427 status: AttachmentStatus::Offered,
5428 cache_path: None,
5429 saved_path: None,
5430 error: None,
5431 encrypted,
5432 wrapped_key: encrypted_meta.as_ref().map(|m| m.wrapped_key_b64.clone()),
5433 nonce: encrypted_meta.as_ref().map(|m| m.nonce_b64.clone()),
5434 megolm_session_id: encrypted_meta.as_ref().map(|m| m.megolm_session_id.clone()),
5435 content_hash: encrypted_meta.as_ref().map(|m| m.content_hash.clone()),
5436 created_at: now_unix(),
5437 };
5438 if let Err(e) = repo::upsert_attachment(&self.db, &attachment) {
5439 warn!(%e, "upsert attachment");
5440 return;
5441 }
5442 // If chunks started arriving before this offer, the transfer's
5443 // size denominator was a guess — correct it with the real size.
5444 self.file_manager.set_expected_size(&file_id, size_bytes);
5445 let _ = self.app_event_tx.send(AppEvent::FileOffered {
5446 room_id: room_id.to_string(),
5447 file_id,
5448 name,
5449 size_bytes,
5450 sender_fingerprint,
5451 });
5452 }
5453
5454 fn handle_file_chunk(
5455 &self,
5456 room_id: &str,
5457 _sender_fingerprint: String,
5458 file_id: String,
5459 chunk_index: u32,
5460 total_chunks: u32,
5461 data_b64: String,
5462 ) {
5463 let data = match B64.decode(&data_b64) {
5464 Ok(d) => d,
5465 Err(e) => {
5466 warn!(%e, "bad chunk base64");
5467 return;
5468 }
5469 };
5470 // Pull the announced size + lifecycle state from our stored offer.
5471 // A terminal-state row means the user cancelled or the transfer
5472 // already failed — late chunks must not resurrect it.
5473 let expected_size = match repo::get_attachment(&self.db, room_id, &file_id) {
5474 Ok(Some(a)) => {
5475 if matches!(
5476 a.status,
5477 AttachmentStatus::Cancelled | AttachmentStatus::Failed
5478 ) {
5479 return;
5480 }
5481 a.size_bytes as u64
5482 }
5483 Ok(None) => crate::files::MAX_FILE_SIZE,
5484 Err(e) => {
5485 warn!(%e, "get attachment for chunk");
5486 crate::files::MAX_FILE_SIZE
5487 }
5488 };
5489
5490 let result = self.file_manager.accept_chunk(
5491 &file_id,
5492 chunk_index,
5493 total_chunks,
5494 data,
5495 expected_size,
5496 );
5497 match result {
5498 Ok(None) => {
5499 // Move offered → downloading on first chunk.
5500 let _ = repo::update_attachment_status(
5501 &self.db,
5502 room_id,
5503 &file_id,
5504 AttachmentStatus::Downloading,
5505 None,
5506 );
5507 // Best-effort progress event — we know we've processed
5508 // (chunk_index+1)/total_chunks chunks.
5509 let bytes_so_far = self
5510 .file_manager
5511 .progress(&file_id)
5512 .map(|(b, _)| b)
5513 .unwrap_or(0);
5514 let _ = self.app_event_tx.send(AppEvent::FileProgress {
5515 file_id: file_id.clone(),
5516 bytes_received: bytes_so_far,
5517 total_bytes: expected_size,
5518 });
5519 }
5520 Ok(Some(completed)) => {
5521 let _ = repo::update_attachment_paths(
5522 &self.db,
5523 room_id,
5524 &file_id,
5525 Some(&completed.cache_path.to_string_lossy()),
5526 None,
5527 );
5528 let _ = repo::update_attachment_status(
5529 &self.db,
5530 room_id,
5531 &file_id,
5532 AttachmentStatus::Ready,
5533 None,
5534 );
5535 let _ = self.app_event_tx.send(AppEvent::FileReady {
5536 file_id: file_id.clone(),
5537 });
5538 }
5539 Err(e) => {
5540 let msg = e.to_string();
5541 warn!(%msg, "chunk processing failed");
5542 let _ = repo::update_attachment_status(
5543 &self.db,
5544 room_id,
5545 &file_id,
5546 AttachmentStatus::Failed,
5547 Some(&msg),
5548 );
5549 let _ = self.app_event_tx.send(AppEvent::FileFailed {
5550 file_id: file_id.clone(),
5551 reason: msg,
5552 });
5553 }
5554 }
5555 }
5556
5557 /// Emit MentionReceived if `body` contains either our full
5558 /// fingerprint or our `HD-XXXX-XXXX` 8-hex-char prefix.
5559 ///
5560 /// huddle 0.7.11: pre-0.7.11 the short-form match used only the
5561 /// first 4-hex group (~65 K possibilities), so unrelated peers
5562 /// sharing a prefix triggered false mentions — and a hostile peer
5563 /// could weaponize a 4-hex literal in their message body to spam
5564 /// the victim's terminal bell, bypassing per-room mute. Bumping to
5565 /// the first 8 hex chars makes the search space 16^8 ≈ 4 billion
5566 /// and effectively eliminates collisions while still being short
5567 /// enough to type as a mention ("hey HD-a3b1c2d4 …").
5568 fn maybe_emit_mention(&self, room_id: &str, body: &str) {
5569 let full = self.identity.fingerprint().to_lowercase();
5570 // First 8 hex chars (two dash-separated groups joined), e.g.
5571 // "a3b1c2d4" of "a3b1-c2d4-…".
5572 let short: String = full.chars().filter(|c| c.is_ascii_hexdigit()).take(8).collect();
5573 let lower = body.to_lowercase();
5574 let hit = lower.contains(full.as_str())
5575 || lower
5576 .split(|c: char| !c.is_ascii_hexdigit())
5577 .any(|tok| tok == short);
5578 if hit {
5579 let _ = self.app_event_tx.send(AppEvent::MentionReceived {
5580 room_id: room_id.to_string(),
5581 body: body.to_string(),
5582 });
5583 }
5584 }
5585
5586 fn decrypt_attachment(
5587 &self,
5588 room_id: &str,
5589 sender_fingerprint: &str,
5590 ciphertext: &[u8],
5591 meta: &EncryptedFileMeta,
5592 ) -> Result<Vec<u8>> {
5593 let mut rooms = self.active_rooms.lock().unwrap();
5594 let room = rooms
5595 .get_mut(room_id)
5596 .ok_or_else(|| HuddleError::Other("not in room".into()))?;
5597 let crypto = room
5598 .crypto
5599 .as_mut()
5600 .ok_or_else(|| HuddleError::Session("missing room crypto".into()))?;
5601 file_encryption::decrypt_file(ciphertext, meta, crypto, sender_fingerprint)
5602 }
5603
5604 /// huddle 0.5: irreversibly delete this account. Verifies the
5605 /// master passphrase, best-effort `MemberLeave`s every joined room
5606 /// (capped at 2 s so a single unresponsive transport can't hang
5607 /// the wipe), shuts down the network, then deletes the database,
5608 /// keychain salt, log, and config files from `config::data_dir()`.
5609 /// Emits `AppEvent::WentDark` on success so the TUI can show a
5610 /// goodbye modal and exit.
5611 ///
5612 /// In `--no-master-passphrase` mode (`self.session_persist_key`
5613 /// is all-zero), the passphrase check is skipped — the typed
5614 /// `DELETE EVERYTHING` confirmation in the TUI is the only gate.
5615 pub async fn go_dark(&self, master_passphrase: &str) -> Result<()> {
5616 let no_master = self.session_persist_key == [0u8; 32];
5617 if !no_master {
5618 let salt = storage::keychain::load_or_create_salt()?;
5619 let candidate_master =
5620 storage::keychain::derive_master_key(master_passphrase, &salt)?;
5621 let candidate_subkey =
5622 storage::keychain::derive_subkey(&candidate_master, b"megolm-persist");
5623 if !ct_eq_32(&candidate_subkey, &self.session_persist_key) {
5624 return Err(HuddleError::Other(
5625 "incorrect master passphrase".into(),
5626 ));
5627 }
5628 }
5629
5630 let room_ids: Vec<String> = self
5631 .active_rooms
5632 .lock()
5633 .unwrap()
5634 .keys()
5635 .cloned()
5636 .collect();
5637 let _ = tokio::time::timeout(Duration::from_secs(2), async {
5638 for room_id in &room_ids {
5639 if let Err(e) = self.leave_room(room_id).await {
5640 warn!(%room_id, %e, "go_dark: leave_room failed");
5641 }
5642 }
5643 })
5644 .await;
5645
5646 self.network.shutdown().await;
5647 tokio::time::sleep(Duration::from_millis(300)).await;
5648
5649 let data_dir = config::data_dir();
5650 let candidates = [
5651 "huddle.db",
5652 "huddle.db-shm",
5653 "huddle.db-wal",
5654 "keychain.salt",
5655 "huddle.log",
5656 "config.toml",
5657 ];
5658 for name in &candidates {
5659 let path = data_dir.join(name);
5660 wipe_file(&path);
5661 }
5662 if let Ok(read) = std::fs::read_dir(&data_dir) {
5663 for entry in read.flatten() {
5664 if let Some(name) = entry.file_name().to_str() {
5665 if name.starts_with("huddle.log.") {
5666 wipe_file(&entry.path());
5667 }
5668 }
5669 }
5670 }
5671 // huddle 0.5.1: wipe the attachment cache directory. Each file
5672 // inside is best-effort zeroed first, then the directory
5673 // itself is removed.
5674 let files_dir = data_dir.join("files");
5675 if let Ok(read) = std::fs::read_dir(&files_dir) {
5676 for entry in read.flatten() {
5677 let path = entry.path();
5678 if path.is_file() {
5679 wipe_file(&path);
5680 } else if path.is_dir() {
5681 // Two-level nesting (room_id subdirs) — sweep their
5682 // contents too.
5683 if let Ok(inner) = std::fs::read_dir(&path) {
5684 for inner_entry in inner.flatten() {
5685 if inner_entry.path().is_file() {
5686 wipe_file(&inner_entry.path());
5687 }
5688 }
5689 }
5690 let _ = std::fs::remove_dir(&path);
5691 }
5692 }
5693 }
5694 let _ = std::fs::remove_dir(&files_dir);
5695 let _ = std::fs::remove_dir(&data_dir);
5696
5697 let _ = self.app_event_tx.send(AppEvent::WentDark);
5698 Ok(())
5699 }
5700}
5701
5702/// huddle 0.5.1: parse `input` as a huddle ID — either `HD-`-prefixed
5703/// or a bare 24-char hex run with or without dashes — and return it in
5704/// the canonical lowercase-dashed form `xxxx-xxxx-...-xxxx` that
5705/// matches `identity::compute_fingerprint`'s output. Returns None for
5706/// anything that isn't a syntactic ID (the caller falls back to
5707/// username lookup).
5708pub fn normalize_to_fingerprint(input: &str) -> Option<String> {
5709 let s = input
5710 .trim()
5711 .trim_start_matches("HD-")
5712 .trim_start_matches("hd-")
5713 .to_string();
5714 let hex_only: String = s.chars().filter(|c| *c != '-').collect();
5715 if hex_only.len() != 24 || !hex_only.chars().all(|c| c.is_ascii_hexdigit()) {
5716 return None;
5717 }
5718 let lower = hex_only.to_ascii_lowercase();
5719 let chunks: Vec<String> = lower
5720 .as_bytes()
5721 .chunks(4)
5722 .map(|c| std::str::from_utf8(c).unwrap().to_string())
5723 .collect();
5724 Some(chunks.join("-"))
5725}
5726
5727/// huddle 1.2.1: length of a connect code (chars), matching the relay's
5728/// `CONNECT_TOKEN_LEN`.
5729pub const CONNECT_CODE_LEN: usize = 8;
5730/// Crockford base32 alphabet (no I/L/O/U) — matches the relay's generator.
5731const CONNECT_CODE_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
5732
5733/// huddle 1.2.1: canonicalize a typed connect code (uppercase, strip spaces /
5734/// dashes) and validate it's exactly `CONNECT_CODE_LEN` Crockford-base32
5735/// chars. Returns `None` for anything that isn't a well-formed code — so the
5736/// UIs can tell a connect code apart from an HD-ID (24 hex) or a username, and
5737/// route "add by …" input to the right path.
5738pub fn normalize_connect_code(input: &str) -> Option<String> {
5739 let up: String = input
5740 .trim()
5741 .to_ascii_uppercase()
5742 .chars()
5743 .filter(|c| *c != '-' && *c != ' ')
5744 .collect();
5745 if up.len() == CONNECT_CODE_LEN && up.bytes().all(|b| CONNECT_CODE_ALPHABET.contains(&b)) {
5746 Some(up)
5747 } else {
5748 None
5749 }
5750}
5751
5752/// huddle 0.5.2: rank a multiaddr by transport preference. Lower =
5753/// better. Used to sort candidate addresses for the parallel dialer so
5754/// LAN connections get a head-start over relay-hopped ones when wall-
5755/// times are close. The numeric values are arbitrary; only the
5756/// ordering matters.
5757fn address_preference(addr: &str) -> u8 {
5758 if addr.contains("/p2p-circuit") {
5759 return 9; // relay-hopped — bottom of the list
5760 }
5761 if let Some(rest) = addr.strip_prefix("/ip4/") {
5762 if let Some(ip_str) = rest.split('/').next() {
5763 if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
5764 if ip.is_loopback() {
5765 return 1; // useful for tests
5766 }
5767 if is_rfc1918(&ip) || ip.is_link_local() {
5768 return 0; // LAN — wins ties
5769 }
5770 return 3; // public ipv4
5771 }
5772 }
5773 return 3;
5774 }
5775 if addr.starts_with("/ip6/") {
5776 return 4;
5777 }
5778 if addr.starts_with("/dns4/") || addr.starts_with("/dns6/") || addr.starts_with("/dnsaddr/") {
5779 return 5;
5780 }
5781 7
5782}
5783
5784/// True for IPv4 addresses in private (RFC 1918) ranges — 10/8,
5785/// 172.16/12, 192.168/16. Used by `address_preference` to score LAN
5786/// dials ahead of public-IP and relay-hopped ones.
5787fn is_rfc1918(ip: &std::net::Ipv4Addr) -> bool {
5788 let octets = ip.octets();
5789 octets[0] == 10
5790 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
5791 || (octets[0] == 192 && octets[1] == 168)
5792}
5793
5794/// Short label for an HD ID, used only in error messages — strips the
5795/// fingerprint down to its first four hex chars with the brand prefix
5796/// so the message reads naturally.
5797fn short_fp_for_msg(fingerprint: &str) -> String {
5798 let head: String = fingerprint
5799 .chars()
5800 .filter(|c| *c != '-')
5801 .take(4)
5802 .collect::<String>()
5803 .to_ascii_uppercase();
5804 format!("HD-{}…", head)
5805}
5806
5807/// Constant-time 32-byte equality. Used by `go_dark` to compare a
5808/// re-derived HKDF subkey to the in-memory `session_persist_key`
5809/// without leaking timing information about which byte differed.
5810fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
5811 let mut diff = 0u8;
5812 for i in 0..32 {
5813 diff |= a[i] ^ b[i];
5814 }
5815 diff == 0
5816}
5817
5818/// Best-effort file wipe: overwrite with zeros, then delete. Missing /
5819/// permission-denied files are logged and skipped. Called from
5820/// `go_dark` only — not a general-purpose util.
5821fn wipe_file(path: &Path) {
5822 use std::io::Write;
5823 // huddle 0.7.11: write zeros in a 64 KiB scratch buffer instead of
5824 // allocating a vec the full file size. The original implementation
5825 // OOM'd `go_dark` mid-wipe whenever a user had downloaded a
5826 // multi-GB attachment — the panic aborted before DB / config wipe,
5827 // leaving a half-wiped data dir.
5828 const SCRATCH: usize = 64 * 1024;
5829 if let Ok(meta) = std::fs::metadata(path) {
5830 if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
5831 let zeros = [0u8; SCRATCH];
5832 let mut remaining = meta.len();
5833 while remaining > 0 {
5834 let n = remaining.min(SCRATCH as u64) as usize;
5835 if f.write_all(&zeros[..n]).is_err() {
5836 break;
5837 }
5838 remaining -= n as u64;
5839 }
5840 let _ = f.sync_all();
5841 }
5842 }
5843 if let Err(e) = std::fs::remove_file(path) {
5844 if e.kind() != std::io::ErrorKind::NotFound {
5845 warn!(?path, %e, "wipe_file: remove failed");
5846 }
5847 }
5848}
5849
5850/// Use the platform's default opener on `path`.
5851fn open_with_system(path: &str) -> Result<()> {
5852 #[cfg(target_os = "macos")]
5853 let cmd = "open";
5854 #[cfg(target_os = "linux")]
5855 let cmd = "xdg-open";
5856 #[cfg(target_os = "windows")]
5857 let cmd = "cmd";
5858 #[cfg(target_os = "windows")]
5859 let args = vec!["/C", "start", "", path];
5860 #[cfg(not(target_os = "windows"))]
5861 let args = vec![path];
5862
5863 std::process::Command::new(cmd)
5864 .args(args)
5865 .spawn()
5866 .map_err(|e| HuddleError::Other(format!("spawn opener: {e}")))?;
5867 Ok(())
5868}
5869
5870// Module-level salt cache: room_id -> salt. Populated when we receive
5871// announcements; queried by join_room.
5872static ROOM_SALT_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<u8>>>> =
5873 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
5874
5875/// huddle 1.1.4: keep `ROOM_SALT_CACHE` bounded. A long-lived client that
5876/// observes many room announcements could otherwise grow it without limit.
5877/// Salts are cheaply re-learned from the next announcement, so evicting an
5878/// arbitrary entry once the cap is reached is harmless.
5879const ROOM_SALT_CACHE_CAP: usize = 4096;
5880
5881fn remember_room_salt(room_id: &str, salt: Vec<u8>) {
5882 let mut cache = ROOM_SALT_CACHE.lock().unwrap();
5883 if !cache.contains_key(room_id) && cache.len() >= ROOM_SALT_CACHE_CAP {
5884 if let Some(k) = cache.keys().next().cloned() {
5885 cache.remove(&k);
5886 }
5887 }
5888 cache.insert(room_id.to_string(), salt);
5889}
5890
5891/// Public accessor for the Argon2id salt length used when deriving room
5892/// passphrase keys. Exists so downstream tooling (status pages, debug
5893/// CLIs, integration tests) can confirm the expected size without
5894/// re-importing the constant from `crypto::passphrase`.
5895pub fn salt_len() -> usize {
5896 SALT_LEN
5897}
5898
5899fn now_unix() -> i64 {
5900 SystemTime::now()
5901 .duration_since(UNIX_EPOCH)
5902 .unwrap()
5903 .as_secs() as i64
5904}
5905
5906fn now_unix_ms() -> i64 {
5907 SystemTime::now()
5908 .duration_since(UNIX_EPOCH)
5909 .unwrap()
5910 .as_millis() as i64
5911}
5912
5913/// Phase B: generate a fresh 24-char base64-ish passphrase for the
5914/// rotation that follows a kick. Sourced from `OsRng` directly so the
5915/// kicker doesn't have to think up a strong one on the spot. Returned
5916/// to the owner via the kick-result modal for OOB sharing with the
5917/// remaining members.
5918fn generate_join_passphrase() -> String {
5919 use rand::RngCore;
5920 let mut bytes = [0u8; 16];
5921 rand::thread_rng().fill_bytes(&mut bytes);
5922 // Use URL-safe-no-pad so the user can read aloud / paste without
5923 // worrying about `=` padding or `+` getting URL-escaped.
5924 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
5925}
5926
5927/// Phase F: short human-readable join code. 8 chars from a 31-symbol
5928/// alphabet (no easily-confused chars like 0/O/I/1/L) ≈ 39.6 bits —
5929/// plenty for a 10-minute online gate since the owner's client checks
5930/// exact-match (not brute-force-able offline).
5931///
5932/// huddle 0.7.11: comment said "32-symbol" but the literal contains 31
5933/// bytes (A-Z minus I/L/O = 23, plus 2-9 = 8, total 31). Doc updated
5934/// to match.
5935fn generate_alphanumeric_code(len: usize) -> String {
5936 use rand::Rng;
5937 const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
5938 let mut rng = rand::thread_rng();
5939 let mut out = String::with_capacity(len + 1);
5940 for i in 0..len {
5941 if i == 4 && len == 8 {
5942 out.push('-'); // pretty: XXXX-XXXX
5943 }
5944 let idx = rng.gen_range(0..ALPHABET.len());
5945 out.push(ALPHABET[idx] as char);
5946 }
5947 out
5948}
5949
5950#[cfg(test)]
5951mod parser_tests {
5952 use super::parse_dial_address;
5953
5954 #[test]
5955 fn parses_ipv4_port() {
5956 let m = parse_dial_address("10.3.72.53:9027").unwrap();
5957 assert_eq!(m.to_string(), "/ip4/10.3.72.53/tcp/9027");
5958 }
5959
5960 #[test]
5961 fn parses_bracketed_ipv6() {
5962 let m = parse_dial_address("[::1]:9027").unwrap();
5963 assert_eq!(m.to_string(), "/ip6/::1/tcp/9027");
5964 }
5965
5966 #[test]
5967 fn rejects_unbracketed_ipv6() {
5968 let err = parse_dial_address("fe80::1:9027").unwrap_err();
5969 assert!(err.to_string().contains("brackets"));
5970 }
5971
5972 #[test]
5973 fn passes_through_raw_multiaddr() {
5974 let m = parse_dial_address("/ip4/1.2.3.4/tcp/9000").unwrap();
5975 assert_eq!(m.to_string(), "/ip4/1.2.3.4/tcp/9000");
5976 }
5977
5978 #[test]
5979 fn empty_address_is_error() {
5980 assert!(parse_dial_address(" ").is_err());
5981 }
5982
5983 #[test]
5984 fn rejects_bad_port() {
5985 assert!(parse_dial_address("1.2.3.4:notaport").is_err());
5986 }
5987}
5988
5989#[cfg(test)]
5990mod transport_preference_tests {
5991 use super::{address_preference, normalize_to_fingerprint};
5992
5993 #[test]
5994 fn lan_beats_public_beats_circuit() {
5995 let lan = address_preference("/ip4/192.168.1.5/tcp/9027");
5996 let pub_v4 = address_preference("/ip4/8.8.8.8/tcp/9027");
5997 let circuit = address_preference(
5998 "/ip4/1.2.3.4/tcp/4001/p2p/12D3Koo/p2p-circuit/p2p/12D3KooXYZ",
5999 );
6000 assert!(lan < pub_v4, "LAN {} should beat public {}", lan, pub_v4);
6001 assert!(
6002 pub_v4 < circuit,
6003 "public {} should beat circuit {}",
6004 pub_v4,
6005 circuit
6006 );
6007 }
6008
6009 #[test]
6010 fn all_rfc1918_ranges_are_lan() {
6011 assert_eq!(
6012 address_preference("/ip4/10.0.0.1/tcp/9027"),
6013 address_preference("/ip4/192.168.0.1/tcp/9027"),
6014 );
6015 assert_eq!(
6016 address_preference("/ip4/172.16.0.1/tcp/9027"),
6017 address_preference("/ip4/192.168.0.1/tcp/9027"),
6018 );
6019 // 172.32.x.x is OUTSIDE the 172.16-31 RFC1918 slice.
6020 assert!(
6021 address_preference("/ip4/172.32.0.1/tcp/9027")
6022 > address_preference("/ip4/172.16.0.1/tcp/9027")
6023 );
6024 }
6025
6026 #[test]
6027 fn normalize_id_accepts_branded_and_raw() {
6028 let canon = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6029 assert_eq!(
6030 normalize_to_fingerprint("HD-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF").as_deref(),
6031 Some(canon)
6032 );
6033 assert_eq!(
6034 normalize_to_fingerprint("aaaabbbbccccddddeeeeffff").as_deref(),
6035 Some(canon)
6036 );
6037 assert_eq!(normalize_to_fingerprint(canon).as_deref(), Some(canon));
6038 assert!(normalize_to_fingerprint("alice").is_none());
6039 assert!(normalize_to_fingerprint("HD-ZZZZ").is_none());
6040 }
6041}
6042
6043#[cfg(test)]
6044mod canonical_dm_room_id_tests {
6045 use super::canonical_dm_room_id;
6046
6047 #[test]
6048 fn dm_room_id_is_commutative() {
6049 // The single load-bearing property: both peers, no matter who
6050 // calls `start_direct` first, derive identical IDs.
6051 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6052 let b = "1111-2222-3333-4444-5555-6666";
6053 assert_eq!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, a));
6054 }
6055
6056 #[test]
6057 fn dm_room_id_differs_per_pair() {
6058 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6059 let b = "1111-2222-3333-4444-5555-6666";
6060 let c = "9999-8888-7777-6666-5555-4444";
6061 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(a, c));
6062 assert_ne!(canonical_dm_room_id(a, b), canonical_dm_room_id(b, c));
6063 }
6064
6065 #[test]
6066 fn dm_room_id_is_stable() {
6067 // Deterministic by construction; this guards against
6068 // accidentally mixing in a timestamp or nonce in a future
6069 // refactor — that would break idempotency across peers.
6070 let a = "aaaa-bbbb-cccc-dddd-eeee-ffff";
6071 let b = "1111-2222-3333-4444-5555-6666";
6072 let id1 = canonical_dm_room_id(a, b);
6073 let id2 = canonical_dm_room_id(a, b);
6074 assert_eq!(id1, id2);
6075 // Same length as `derive_room_id` output (32 hex chars / 16
6076 // bytes) so DM IDs are indistinguishable from group IDs at the
6077 // topic-name layer.
6078 assert_eq!(id1.len(), 32);
6079 }
6080}