fips-core 0.3.1

Reusable FIPS mesh, endpoint, transport, and protocol library
Documentation
//! LAN peer discovery via mDNS / DNS-SD (RFC 6762 / RFC 6763).
//!
//! Publishes a `_fips._udp.local.` service advert carrying our `npub` and
//! discovery scope on the local link, and concurrently browses for the
//! same service type to learn peers reachable on the same broadcast
//! domain. The result is sub-second peer pairing without any Nostr-relay
//! roundtrip, STUN observation, or NAT traversal — the observed
//! endpoint is by construction routable from the consumer's LAN.
//!
//! ## Trust model
//!
//! mDNS adverts are unauthenticated: anyone on the LAN can multicast a
//! TXT carrying `npub=...`. Identity is still proven end-to-end by the
//! Noise XX handshake the Node initiates against the observed endpoint
//! — a spoofed advert with another peer's npub fails the handshake and
//! is silently dropped. Treat the mDNS advert as a routing hint, not as
//! identity. The information leaked (our npub plus a LAN endpoint) is a
//! subset of what we already publish on Nostr relays for every peer in
//! the world to read, so there's no marginal privacy cost.
//!
//! ## Scope filtering
//!
//! When a `discovery_scope` is configured, the advert carries it in a
//! `scope=<name>` TXT entry and the browser only surfaces peers with a
//! matching scope. Nodes on the same physical LAN but configured for
//! different mesh networks don't cross-feed each other.

use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;

use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};

use crate::Identity;

/// DNS-SD service type for the FIPS LAN advert. RFC 6763 §4.1.2: must
/// end with `.local.`. The `_udp` is the IP transport, not the upper
/// protocol — both UDP and TCP FIPS endpoints announce under the same
/// service type because the link-layer punch/handshake travels over UDP
/// either way.
pub const SERVICE_TYPE: &str = "_fips._udp.local.";

/// TXT key carrying the bech32-encoded npub of the publishing node.
pub const TXT_KEY_NPUB: &str = "npub";

/// TXT key carrying the publishing node's `discovery_scope`, if any.
pub const TXT_KEY_SCOPE: &str = "scope";

/// TXT key carrying the FIPS protocol version (matches the Nostr advert
/// `PROTOCOL_VERSION`).
pub const TXT_KEY_VERSION: &str = "v";

#[derive(Debug, Error)]
pub enum LanDiscoveryError {
    #[error("mDNS daemon init failed: {0}")]
    Daemon(String),
    #[error("mDNS register failed: {0}")]
    Register(String),
    #[error("mDNS browse failed: {0}")]
    Browse(String),
    #[error("no advertised UDP port — start a UDP transport first")]
    NoAdvertisedPort,
    #[error("LAN discovery disabled in config")]
    Disabled,
}

/// A peer we learned about via mDNS. Identity is unverified at this
/// point; the Node initiates a Noise XX handshake against `addr` to
/// confirm `npub` actually controls the matching private key.
#[derive(Debug, Clone)]
pub struct LanDiscoveredPeer {
    pub npub: String,
    pub scope: Option<String>,
    pub addr: SocketAddr,
    pub observed_at: Instant,
}

/// Browser-side events surfaced by `LanDiscovery::drain_events`.
#[derive(Debug, Clone)]
pub enum LanEvent {
    Discovered(LanDiscoveredPeer),
}

/// Runtime configuration for the mDNS responder + browser.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LanDiscoveryConfig {
    /// Master switch. Default: `true` — we already publish identity on
    /// Nostr relays; the marginal leak from also multicasting on the LAN
    /// is zero, while the latency win is large for same-LAN peers.
    #[serde(default = "LanDiscoveryConfig::default_enabled")]
    pub enabled: bool,
    /// Overridable service type, primarily so integration tests can run
    /// multiple isolated services on the same loopback interface.
    #[serde(default = "LanDiscoveryConfig::default_service_type")]
    pub service_type: String,
}

impl Default for LanDiscoveryConfig {
    fn default() -> Self {
        Self {
            enabled: Self::default_enabled(),
            service_type: Self::default_service_type(),
        }
    }
}

impl LanDiscoveryConfig {
    fn default_enabled() -> bool {
        true
    }
    fn default_service_type() -> String {
        SERVICE_TYPE.to_string()
    }
}

/// Running mDNS responder + browser bound to the node's UDP advert port.
pub struct LanDiscovery {
    daemon: ServiceDaemon,
    own_npub: String,
    instance_fullname: String,
    events_rx: Mutex<tokio::sync::mpsc::UnboundedReceiver<LanEvent>>,
    event_pump: tokio::task::JoinHandle<()>,
}

impl LanDiscovery {
    /// Start the mDNS responder and browser.
    ///
    /// `advertised_port` is the UDP port the operational UDP transport
    /// is bound to — peers receiving our advert will initiate Noise XX
    /// against that port. `scope` mirrors the Nostr discovery scope and
    /// is used to filter the browser stream.
    pub async fn start(
        identity: &Identity,
        scope: Option<String>,
        advertised_port: u16,
        config: LanDiscoveryConfig,
    ) -> Result<Arc<Self>, LanDiscoveryError> {
        if !config.enabled {
            return Err(LanDiscoveryError::Disabled);
        }
        if advertised_port == 0 {
            return Err(LanDiscoveryError::NoAdvertisedPort);
        }

        let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;

        let npub = identity.npub();
        // mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
        // give 80 bits of effective entropy — collisions on a single LAN
        // are vanishingly unlikely. Prefixed for human-readable logs.
        let label_npub = &npub[..16.min(npub.len())];
        let instance_name = format!("fips-{label_npub}");
        let host_name = format!("{instance_name}.local.");

        let mut props: HashMap<String, String> = HashMap::new();
        props.insert(TXT_KEY_NPUB.to_string(), npub.clone());
        if let Some(s) = scope.as_deref()
            && !s.is_empty()
        {
            props.insert(TXT_KEY_SCOPE.to_string(), s.to_string());
        }
        props.insert(
            TXT_KEY_VERSION.to_string(),
            super::nostr::PROTOCOL_VERSION.to_string(),
        );

        // host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
        // called: the loopback seed makes the advert resolve for
        // same-host peers (and same-host integration tests) while the
        // auto-flag still appends every non-loopback interface address
        // mdns-sd discovers. Belt-and-braces because addr_auto alone
        // skips loopback by default on some platforms.
        let service_info = ServiceInfo::new(
            &config.service_type,
            &instance_name,
            &host_name,
            "127.0.0.1",
            advertised_port,
            Some(props),
        )
        .map_err(|e| LanDiscoveryError::Register(e.to_string()))?
        .enable_addr_auto();

        let instance_fullname = service_info.get_fullname().to_string();

        daemon
            .register(service_info)
            .map_err(|e| LanDiscoveryError::Register(e.to_string()))?;

        let browse_rx = daemon
            .browse(&config.service_type)
            .map_err(|e| LanDiscoveryError::Browse(e.to_string()))?;

        let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
        let own_npub = npub.clone();
        let scope_filter = scope.clone().filter(|s| !s.is_empty());
        let event_pump = tokio::spawn(async move {
            // mdns-sd browse returns a flume::Receiver; pump until the
            // daemon shuts down and the channel closes.
            loop {
                let event = match browse_rx.recv_async().await {
                    Ok(e) => e,
                    Err(_) => break,
                };
                match event {
                    ServiceEvent::ServiceResolved(info) => {
                        let mut peer_npub: Option<String> = None;
                        let mut peer_scope: Option<String> = None;
                        for prop in info.get_properties().iter() {
                            match prop.key() {
                                TXT_KEY_NPUB => {
                                    peer_npub = Some(prop.val_str().to_string());
                                }
                                TXT_KEY_SCOPE => {
                                    peer_scope = Some(prop.val_str().to_string());
                                }
                                _ => {}
                            }
                        }
                        let Some(peer_npub) = peer_npub else {
                            debug!(
                                instance = info.get_fullname(),
                                "lan: skip advert without npub TXT"
                            );
                            continue;
                        };
                        if peer_npub == own_npub {
                            // Our own advert echoed back on a loopback
                            // or multi-homed interface.
                            continue;
                        }
                        if scope_filter.is_some() && scope_filter != peer_scope {
                            debug!(
                                npub = %short(&peer_npub),
                                their_scope = ?peer_scope,
                                our_scope = ?scope_filter,
                                "lan: skip cross-scope advert"
                            );
                            continue;
                        }
                        let port = info.get_port();
                        if port == 0 {
                            continue;
                        }
                        let observed_at = Instant::now();
                        // mdns-sd may report multiple interface IPs for
                        // a multi-homed responder. Surface all of them
                        // — the Node side filters/dedups. mdns-sd 0.19
                        // returns `ScopedIp` (with optional IPv6 zone);
                        // we strip the zone here and pass plain IpAddr
                        // — the consumer routes via standard tables.
                        for scoped in info.get_addresses() {
                            let ip = scoped.to_ip_addr();
                            let addr = SocketAddr::new(ip, port);
                            if events_tx
                                .send(LanEvent::Discovered(LanDiscoveredPeer {
                                    npub: peer_npub.clone(),
                                    scope: peer_scope.clone(),
                                    addr,
                                    observed_at,
                                }))
                                .is_err()
                            {
                                return;
                            }
                        }
                    }
                    ServiceEvent::ServiceRemoved(_, fullname) => {
                        debug!(fullname = %fullname, "lan: service removed");
                    }
                    other => {
                        debug!(?other, "lan: mDNS event");
                    }
                }
            }
        });

        info!(
            instance = %instance_fullname,
            port = advertised_port,
            scope = ?scope,
            "lan: mDNS discovery started"
        );
        Ok(Arc::new(Self {
            daemon,
            own_npub: npub,
            instance_fullname,
            events_rx: Mutex::new(events_rx),
            event_pump,
        }))
    }

    /// Bech32 npub published by this node.
    pub fn own_npub(&self) -> &str {
        &self.own_npub
    }

    /// Drain pending browser events. Called once per Node tick.
    pub async fn drain_events(&self) -> Vec<LanEvent> {
        let mut rx = self.events_rx.lock().await;
        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }
        events
    }

    /// Tear down the responder, browser, and event pump.
    pub async fn shutdown(self: &Arc<Self>) {
        if let Err(e) = self.daemon.unregister(&self.instance_fullname) {
            warn!(error = %e, "lan: unregister failed");
        }
        if let Err(e) = self.daemon.shutdown() {
            warn!(error = %e, "lan: daemon shutdown failed");
        }
        self.event_pump.abort();
    }
}

fn short(npub: &str) -> &str {
    let end = 16.min(npub.len());
    &npub[..end]
}

#[cfg(test)]
mod tests;