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