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