Skip to main content

hashtree_cli/
fips_transport.rs

1//! Hashtree daemon integration for the embedded FIPS endpoint API.
2//!
3//! This starts `fips_core::FipsEndpoint` inside the htree process; it does not
4//! depend on or talk to an external FIPS daemon.
5
6use crate::config::{Config, NostrEventTransport};
7#[cfg(feature = "experimental-decentralized-pubsub")]
8use crate::nostr_relay::NostrRelay;
9use crate::storage::{HashtreeStore, StorageRouter};
10use anyhow::{Context, Result};
11use hashtree_core::{BlobRoute, StoreBlobRoute};
12use hashtree_fips_transport::{
13    bind_fips_endpoint, bind_fips_endpoint_at_local_rendezvous, set_fips_peer_configs,
14    BoundFipsEndpoint, FipsBlobRoute, FipsEndpoint, FipsEndpointOptions, FipsPeerConfig,
15    PeerIdentity, TcpBlobTransport, TcpBlobTransportConfig, DEFAULT_FIPS_DISCOVERY_SCOPE,
16};
17use hashtree_network::{BlobRouteEntry, BlobRouter, BlobRouterConfig, MeshForwardingRoute};
18use hashtree_nostr_pubsub::HashtreeNostrBoundedEventCache;
19use nostr::nips::nip19::ToBech32;
20use nostr::PublicKey;
21#[cfg(feature = "experimental-decentralized-pubsub")]
22use nostr_pubsub::{EventBus, Filter, VerifiedEvent};
23use nostr_pubsub::{
24    EventPolicyContext, EventRetentionPolicy, EventSource, NostrPubsubRouter, PolicyDecision,
25    PubsubPolicy, RouterLiveSource, RouterPublishSource, RouterQuerySource, SourcePolicyContext,
26    SourceRoute,
27};
28use nostr_pubsub_fips::{FipsPubsubClient, FipsPubsubClientOptions};
29use std::collections::HashSet;
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32#[cfg(feature = "experimental-decentralized-pubsub")]
33use tokio::sync::mpsc;
34#[cfg(feature = "experimental-decentralized-pubsub")]
35use tokio::task::JoinHandle;
36
37pub type DaemonBlobResolver = BlobRouter;
38type DaemonBlobTransport = TcpBlobTransport<StorageRouter>;
39pub type DaemonNostrCache = HashtreeNostrBoundedEventCache<StorageRouter>;
40
41const DAEMON_SAME_HOST_PROVIDER_PRIORITY: i16 = 100;
42const DAEMON_NOSTR_CACHE_EVENTS: usize = 4_096;
43const BLOB_RESOLVER_REPLY_MARGIN: Duration = Duration::from_secs(1);
44
45pub struct DaemonFipsHandle {
46    pub endpoint: Arc<FipsEndpoint>,
47    pub endpoint_npub: String,
48    pub discovery_scope: String,
49    pub pubsub_client: Option<Arc<FipsPubsubClient>>,
50    pub blob_resolver: Arc<DaemonBlobResolver>,
51    blob_transport: Mutex<Option<Arc<DaemonBlobTransport>>>,
52}
53
54impl DaemonFipsHandle {
55    pub async fn shutdown(&self) {
56        if let Ok(mut transport) = self.blob_transport.lock() {
57            transport.take();
58        }
59        if let Err(err) = self.endpoint.shutdown().await {
60            tracing::warn!("failed to stop embedded FIPS endpoint: {err}");
61        }
62    }
63}
64
65#[cfg(feature = "experimental-decentralized-pubsub")]
66pub struct DaemonNostrPubsubHandle {
67    _client: Arc<FipsPubsubClient>,
68    relay: Arc<NostrRelay>,
69    ingest_task: JoinHandle<()>,
70    outbound_task: JoinHandle<()>,
71}
72
73#[cfg(feature = "experimental-decentralized-pubsub")]
74impl DaemonNostrPubsubHandle {
75    pub fn shutdown(&self) {
76        self.relay.set_decentralized_pubsub_sender(None);
77        self.ingest_task.abort();
78        self.outbound_task.abort();
79    }
80}
81
82pub async fn start_daemon_fips_transport(
83    config: &Config,
84    keys: &nostr::Keys,
85    store: Arc<HashtreeStore>,
86    peer_ids: Vec<String>,
87) -> Result<Option<DaemonFipsHandle>> {
88    if !config.server.enable_fips || !config.server.mode.hash_get_enabled() {
89        return Ok(None);
90    }
91
92    let active_relays = config.nostr.active_relays();
93    let relays = config.server.resolved_fips_relays(&active_relays);
94    let discovery_scope = normalized_discovery_scope(&config.server.fips_discovery_scope);
95    let peer_configs = daemon_fips_peer_configs(config, peer_ids);
96    let identity_nsec = keys
97        .secret_key()
98        .to_bech32()
99        .context("Failed to encode daemon identity for FIPS endpoint")?;
100    let mut options = FipsEndpointOptions::new(identity_nsec);
101    options.discovery_scope = discovery_scope.clone();
102    options.relays = relays;
103    options.enable_udp = config.server.enable_fips_udp;
104    options.enable_webrtc = config.server.enable_fips_webrtc;
105    options.enable_local_rendezvous = true;
106    options.enable_lan_discovery = config.server.enable_fips_lan_discovery;
107    options.ethernet_interfaces = config.server.fips_ethernet_interfaces.clone();
108    options.udp_bind_addr = config.server.fips_udp_bind_addr.clone();
109    options.udp_public = config.server.fips_udp_public;
110    options.udp_external_addr = config.server.fips_udp_external_addr.clone();
111    // A configured/followed peer may be WebRTC-only. Let this endpoint dial
112    // discovered WebRTC adverts so connectivity does not depend on the remote
113    // side winning the offer race.
114    options.webrtc_auto_connect = options.enable_webrtc && !peer_configs.is_empty();
115    options.webrtc_max_connections = hashtree_fips_transport::DEFAULT_FIPS_WEBRTC_MAX_CONNECTIONS;
116    options.open_discovery_max_pending = config.server.fips_open_discovery_max_pending;
117    options.packet_channel_capacity = 1024;
118    let endpoint = if let Some(raw) = config.server.fips_local_rendezvous_addr.as_deref() {
119        let rendezvous_addr = raw
120            .parse()
121            .context("server.fips_local_rendezvous_addr must be an IPv4 socket address")?;
122        bind_fips_endpoint_at_local_rendezvous(options, rendezvous_addr).await
123    } else {
124        bind_fips_endpoint(options).await
125    }
126    .context("Failed to start FIPS endpoint")?;
127    let request_timeout = Duration::from_millis(config.server.fips_request_timeout_ms.max(1));
128    let pubsub_client = if daemon_fips_pubsub_required(config) {
129        let options = FipsPubsubClientOptions {
130            query_timeout: request_timeout,
131            max_frame_bytes: config
132                .nostr
133                .decentralized_pubsub_max_event_bytes
134                .min(nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_FRAME_BYTES),
135            ..Default::default()
136        };
137        Some(Arc::new(
138            FipsPubsubClient::start(endpoint.native_endpoint.clone(), options)
139                .await
140                .context("Failed to start FIPS Nostr pubsub provider")?,
141        ))
142    } else {
143        None
144    };
145    if !peer_configs.is_empty() {
146        set_fips_peer_configs(endpoint.native_endpoint.as_ref(), peer_configs.clone())
147            .await
148            .context("Failed to configure FIPS peers")?;
149    }
150    let (blob_resolver, blob_transport) =
151        bind_daemon_blob_resolver(&endpoint, store.store_arc(), &peer_configs, request_timeout)
152            .await?;
153
154    Ok(Some(DaemonFipsHandle {
155        endpoint: endpoint.native_endpoint,
156        endpoint_npub: endpoint.local_peer_id,
157        discovery_scope: endpoint.discovery_scope,
158        pubsub_client,
159        blob_resolver,
160        blob_transport: Mutex::new(Some(blob_transport)),
161    }))
162}
163
164fn daemon_fips_pubsub_required(config: &Config) -> bool {
165    config.nostr.event_transport == NostrEventTransport::FipsLocalOnly
166        || config.nostr.decentralized_pubsub_enabled()
167}
168
169async fn bind_daemon_blob_resolver(
170    endpoint: &BoundFipsEndpoint,
171    store: Arc<StorageRouter>,
172    peers: &[FipsPeerConfig],
173    request_timeout: Duration,
174) -> Result<(Arc<DaemonBlobResolver>, Arc<DaemonBlobTransport>)> {
175    let mut routes = vec![BlobRouteEntry::new(
176        "configured-store",
177        Arc::new(StoreBlobRoute::new(store.clone())),
178    )];
179    let resolver = Arc::new(
180        BlobRouter::new(
181            routes.clone(),
182            Some(store.clone()),
183            BlobRouterConfig {
184                request_timeout,
185                ..Default::default()
186            },
187        )
188        .map_err(anyhow::Error::msg)
189        .context("Failed to configure the daemon blob router")?,
190    );
191    // Advertise the same resolver used by in-process daemon reads. The FIPS
192    // route added below owns only a weak transport reference, so routing an
193    // inbound request through this resolver does not create an Arc cycle.
194    let inbound_route: Arc<dyn BlobRoute> = resolver.clone();
195    let transport = Arc::new(
196        TcpBlobTransport::bind_advertised_route_with_config(
197            endpoint.native_endpoint.clone(),
198            store.clone(),
199            inbound_route,
200            blob_transport_config(request_timeout),
201            DAEMON_SAME_HOST_PROVIDER_PRIORITY,
202        )
203        .await
204        .context("Failed to advertise htree's blob resolver")?,
205    );
206    let peer_identities: Vec<_> = peers
207        .iter()
208        .filter_map(|peer| PeerIdentity::from_npub(&peer.npub).ok())
209        .collect();
210    if !peer_identities.is_empty() {
211        let max_provider_attempts = peer_identities.len().min(4);
212        let fips_route =
213            FipsBlobRoute::explicit(transport.clone(), peer_identities, max_provider_attempts)
214                .map_err(anyhow::Error::msg)
215                .context("Failed to configure the daemon FIPS blob route")?;
216        routes.push(BlobRouteEntry::new(
217            "configured-fips-peers",
218            Arc::new(MeshForwardingRoute::new(Arc::new(fips_route))),
219        ));
220    }
221    resolver
222        .set_routes(routes)
223        .await
224        .map_err(anyhow::Error::msg)
225        .context("Failed to install daemon blob routes")?;
226    Ok((resolver, transport))
227}
228
229fn blob_transport_config(resolver_timeout: Duration) -> TcpBlobTransportConfig {
230    TcpBlobTransportConfig {
231        idle_timeout: resolver_timeout.saturating_add(BLOB_RESOLVER_REPLY_MARGIN),
232    }
233}
234
235pub fn new_daemon_nostr_cache(store: Arc<StorageRouter>) -> Arc<DaemonNostrCache> {
236    Arc::new(
237        HashtreeNostrBoundedEventCache::new(
238            store,
239            None,
240            EventSource::local_index("hashtree-nostr-cache"),
241            EventRetentionPolicy::new(DAEMON_NOSTR_CACHE_EVENTS, Vec::new()),
242        )
243        .with_priority(nostr_pubsub::SOURCE_PRIORITY_LOCAL_INDEX),
244    )
245}
246
247struct AllowDaemonPubsubRoutes;
248
249#[async_trait::async_trait]
250impl PubsubPolicy for AllowDaemonPubsubRoutes {
251    async fn check_event(
252        &self,
253        _context: EventPolicyContext<'_>,
254    ) -> nostr_pubsub::Result<PolicyDecision> {
255        Ok(PolicyDecision::allow_with_priority(0))
256    }
257
258    async fn check_source(
259        &self,
260        _context: SourcePolicyContext<'_>,
261    ) -> nostr_pubsub::Result<PolicyDecision> {
262        Ok(PolicyDecision::allow_with_priority(0))
263    }
264}
265
266pub async fn start_daemon_nostr_provider(
267    config: &Config,
268    fips_handle: Option<&DaemonFipsHandle>,
269    cache: Option<Arc<DaemonNostrCache>>,
270) -> Result<Option<Arc<dyn nostr_pubsub::PubsubProvider>>> {
271    let mut router = NostrPubsubRouter::new(Arc::new(AllowDaemonPubsubRoutes));
272    if let Some(cache) = cache {
273        let route = cache
274            .source_route("hashtree-local-cache")
275            .context("Failed to configure Hashtree Nostr cache route")?;
276        router = router.with_query_source(RouterQuerySource::new(route, cache));
277    }
278
279    let router = match config.nostr.event_transport {
280        NostrEventTransport::Relay => {
281            let relays = config.nostr.active_relays();
282            if relays.is_empty() {
283                return Ok(None);
284            }
285            let route = SourceRoute::relay(relays.join(","))
286                .with_dataset("configured-relays")
287                .context("Failed to configure Nostr relay route")?;
288            let event_bus = Arc::new(
289                nostr_pubsub_relay::RelayEventBus::new(
290                    relays.clone(),
291                    Duration::from_millis(config.server.fips_request_timeout_ms.max(1)),
292                )
293                .await
294                .context("Failed to start Nostr relay event provider")?,
295            );
296            router
297                .with_query_source(RouterQuerySource::new(
298                    route.clone(),
299                    Arc::clone(&event_bus),
300                ))
301                .with_publish_source(RouterPublishSource::new(
302                    route.clone(),
303                    Arc::clone(&event_bus),
304                ))
305                .with_live_source(RouterLiveSource::new(route, event_bus))
306        }
307        NostrEventTransport::FipsLocalOnly => {
308            let client = fips_handle
309                .and_then(|handle| handle.pubsub_client.clone())
310                .ok_or_else(|| {
311                anyhow::anyhow!(
312                    "nostr.event_transport=fips-local-only requires the local FIPS Nostr pubsub provider"
313                )
314                })?;
315            let route = SourceRoute::fips_peer_default("connected-fips-mesh")
316                .with_dataset("fips-mesh")
317                .context("Failed to configure FIPS Nostr route")?;
318            router
319                .with_query_source(RouterQuerySource::new(route.clone(), Arc::clone(&client)))
320                .with_publish_source(RouterPublishSource::new(route.clone(), Arc::clone(&client)))
321                .with_live_source(RouterLiveSource::new(route, client))
322        }
323    };
324    Ok(Some(Arc::new(router)))
325}
326
327#[cfg(feature = "experimental-decentralized-pubsub")]
328pub async fn start_daemon_nostr_pubsub(
329    config: &Config,
330    fips_handle: Option<&DaemonFipsHandle>,
331    relay: Option<Arc<NostrRelay>>,
332    cache: Arc<DaemonNostrCache>,
333) -> Result<Option<Arc<DaemonNostrPubsubHandle>>> {
334    if !daemon_decentralized_pubsub_ready(config, fips_handle.is_some(), relay.is_some()) {
335        return Ok(None);
336    }
337
338    let fips_handle = fips_handle.expect("checked by daemon_decentralized_pubsub_ready");
339    let relay = relay.expect("checked by daemon_decentralized_pubsub_ready");
340    let client = fips_handle.pubsub_client.clone().ok_or_else(|| {
341        anyhow::anyhow!("decentralized Nostr pubsub requires the shared FIPS pubsub client")
342    })?;
343
344    let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
345    relay.set_decentralized_pubsub_sender(Some(outbound_tx));
346    let ingest_task = spawn_daemon_nostr_pubsub_ingest(
347        Arc::clone(&client),
348        Arc::clone(&fips_handle.endpoint),
349        Arc::clone(&relay),
350        Arc::clone(&cache),
351    );
352    let outbound_task = spawn_daemon_nostr_pubsub_outbound(Arc::clone(&client), outbound_rx, cache);
353
354    Ok(Some(Arc::new(DaemonNostrPubsubHandle {
355        _client: client,
356        relay,
357        ingest_task,
358        outbound_task,
359    })))
360}
361
362#[cfg(feature = "experimental-decentralized-pubsub")]
363fn daemon_decentralized_pubsub_ready(config: &Config, has_fips: bool, has_relay: bool) -> bool {
364    config.nostr.decentralized_pubsub_enabled() && has_fips && has_relay
365}
366
367#[cfg(feature = "experimental-decentralized-pubsub")]
368fn spawn_daemon_nostr_pubsub_ingest(
369    client: Arc<FipsPubsubClient>,
370    endpoint: Arc<FipsEndpoint>,
371    relay: Arc<NostrRelay>,
372    cache: Arc<DaemonNostrCache>,
373) -> JoinHandle<()> {
374    tokio::spawn(async move {
375        loop {
376            let subscribed_peers = connected_fips_peer_ids(&endpoint).await;
377            if subscribed_peers.is_empty() {
378                tokio::time::sleep(Duration::from_millis(250)).await;
379                continue;
380            }
381            let mut subscription = match client.subscribe(vec![Filter::new()]).await {
382                Ok(subscription) => subscription,
383                Err(err) => {
384                    tracing::debug!("waiting to subscribe to FIPS Nostr peers: {err}");
385                    tokio::time::sleep(Duration::from_millis(250)).await;
386                    continue;
387                }
388            };
389            loop {
390                tokio::select! {
391                    delivery = subscription.recv() => {
392                        let Some(delivery) = delivery else {
393                            break;
394                        };
395                        let source = delivery.source.clone();
396                        let source_id = source.id.as_str().to_string();
397                        let verified = delivery.event;
398                        let event = verified.as_event().clone();
399                        let event_id = event.id.to_hex();
400                        match relay.ingest_peer_event_silent(event).await {
401                            Ok(true) => {
402                                if let Err(error) = cache.publish(verified, source).await {
403                                    tracing::warn!(event_id, %error, "failed to cache decentralized Nostr event");
404                                }
405                                tracing::debug!(
406                                    source = source_id,
407                                    event_id,
408                                    "ingested decentralized Nostr pubsub event"
409                                );
410                            }
411                            Ok(false) => {}
412                            Err(err) => tracing::warn!(
413                                source = source_id,
414                                event_id,
415                                "nostr decentralized pubsub ingest failed: {err:#}"
416                            ),
417                        }
418                    }
419                    () = tokio::time::sleep(Duration::from_millis(500)) => {
420                        if connected_fips_peer_ids(&endpoint).await != subscribed_peers {
421                            break;
422                        }
423                    }
424                }
425            }
426        }
427    })
428}
429
430#[cfg(feature = "experimental-decentralized-pubsub")]
431async fn connected_fips_peer_ids(endpoint: &FipsEndpoint) -> Vec<String> {
432    let mut peers = endpoint
433        .peers()
434        .await
435        .unwrap_or_default()
436        .into_iter()
437        .filter(|peer| peer.connected)
438        .map(|peer| peer.npub)
439        .collect::<Vec<_>>();
440    peers.sort_unstable();
441    peers.dedup();
442    peers
443}
444
445#[cfg(feature = "experimental-decentralized-pubsub")]
446fn spawn_daemon_nostr_pubsub_outbound(
447    client: Arc<FipsPubsubClient>,
448    mut outbound_rx: mpsc::UnboundedReceiver<nostr::Event>,
449    cache: Arc<DaemonNostrCache>,
450) -> JoinHandle<()> {
451    tokio::spawn(async move {
452        while let Some(event) = outbound_rx.recv().await {
453            let event_id = event.id.to_hex();
454            let verified = match VerifiedEvent::try_from(event) {
455                Ok(event) => event,
456                Err(err) => {
457                    tracing::warn!(event_id, "invalid outbound Nostr event: {err}");
458                    continue;
459                }
460            };
461            let source = EventSource::local_index("htree-relay");
462            if let Err(error) = cache.publish(verified.clone(), source.clone()).await {
463                tracing::warn!(event_id, %error, "failed to cache outbound Nostr event");
464            }
465            match client.publish(verified, source).await {
466                Ok(report) => tracing::debug!(
467                    event_id,
468                    accepted = report.accepted,
469                    "published Nostr event over decentralized pubsub"
470                ),
471                Err(err) => {
472                    tracing::warn!(event_id, "nostr decentralized pubsub publish failed: {err}")
473                }
474            }
475        }
476    })
477}
478
479pub fn fips_peer_ids_from_pubkeys(pubkeys: Vec<[u8; 32]>) -> Vec<String> {
480    pubkeys
481        .into_iter()
482        .filter_map(|pubkey| PublicKey::from_slice(&pubkey).ok())
483        .filter_map(|pubkey| pubkey.to_bech32().ok())
484        .collect()
485}
486
487pub fn daemon_fips_peer_configs(
488    config: &Config,
489    discovered_peer_ids: Vec<String>,
490) -> Vec<FipsPeerConfig> {
491    let mut seen = HashSet::new();
492    let mut peers = Vec::new();
493
494    for peer in &config.server.fips_peers {
495        let npub = peer.npub.trim().to_string();
496        if npub.is_empty() || !seen.insert(npub.clone()) {
497            continue;
498        }
499        let udp_addresses = peer
500            .udp_addresses
501            .iter()
502            .map(|addr| addr.trim().to_string())
503            .filter(|addr| !addr.is_empty())
504            .collect();
505        peers.push(FipsPeerConfig {
506            npub,
507            udp_addresses,
508        });
509    }
510
511    for peer_id in discovered_peer_ids {
512        let npub = peer_id.trim().to_string();
513        if npub.is_empty() || !seen.insert(npub.clone()) {
514            continue;
515        }
516        peers.push(FipsPeerConfig::new(npub));
517    }
518
519    peers
520}
521
522fn normalized_discovery_scope(scope: &str) -> String {
523    let scope = scope.trim();
524    if scope.is_empty() {
525        DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
526    } else {
527        scope.to_string()
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use hashtree_core::Store;
535    #[cfg(feature = "experimental-decentralized-pubsub")]
536    use nostr::{EventBuilder, Kind, Tag};
537    #[cfg(feature = "experimental-decentralized-pubsub")]
538    use nostr_pubsub::{PubsubProviderMode, QueryOptions};
539    use sha2::{Digest, Sha256};
540    use tokio::time::timeout;
541
542    struct RecordingStoreRoute {
543        store: Arc<hashtree_core::MemoryStore>,
544        requests: Arc<std::sync::Mutex<Vec<hashtree_core::BlobRequest>>>,
545    }
546
547    #[async_trait::async_trait]
548    impl BlobRoute for RecordingStoreRoute {
549        async fn route(
550            &self,
551            request: hashtree_core::BlobRequest,
552        ) -> Result<hashtree_core::BlobReply, hashtree_core::StoreError> {
553            self.requests.lock().unwrap().push(request);
554            StoreBlobRoute::new(self.store.clone()).route(request).await
555        }
556    }
557
558    #[test]
559    fn fips_peer_ids_from_pubkeys_encodes_npbus() {
560        let keys = nostr::Keys::generate();
561        let expected = keys.public_key().to_bech32().unwrap();
562
563        assert_eq!(
564            fips_peer_ids_from_pubkeys(vec![keys.public_key().to_bytes()]),
565            vec![expected]
566        );
567    }
568
569    #[test]
570    fn empty_discovery_scope_uses_hashtree_default() {
571        assert_eq!(
572            normalized_discovery_scope("  "),
573            DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
574        );
575    }
576
577    #[tokio::test]
578    async fn fips_local_only_event_transport_requires_local_provider() {
579        let mut config = Config::default();
580        config.nostr.event_transport = NostrEventTransport::FipsLocalOnly;
581
582        let error = start_daemon_nostr_provider(&config, None, None)
583            .await
584            .err()
585            .expect("missing local FIPS provider must fail");
586
587        assert!(error.to_string().contains("fips-local-only"));
588        assert!(error.to_string().contains("requires"));
589    }
590
591    #[cfg(feature = "experimental-decentralized-pubsub")]
592    #[tokio::test]
593    async fn one_native_endpoint_serves_roots_and_trusted_decentralized_events() {
594        let scope = format!("htree-native-pubsub-{}", uuid::Uuid::new_v4());
595        let daemon_addr = reserve_udp_addr();
596        let rendezvous_addr = reserve_udp_addr();
597        let (remote_endpoint, remote_addr) = udp_endpoint(&scope).await;
598        let daemon_keys = nostr::Keys::generate();
599        let trusted_keys = nostr::Keys::generate();
600        let untrusted_keys = nostr::Keys::generate();
601        let outbound_keys = nostr::Keys::generate();
602        let temp = tempfile::tempdir().unwrap();
603        let store = Arc::new(HashtreeStore::new(temp.path().join("blobs")).unwrap());
604        let relay = trusted_test_relay(
605            temp.path(),
606            &[trusted_keys.public_key(), outbound_keys.public_key()],
607        )
608        .await;
609
610        let mut config = Config::default();
611        config.server.fips_discovery_scope = scope;
612        config.server.fips_relays = Some(Vec::new());
613        config.server.fips_udp_bind_addr = Some(daemon_addr.clone());
614        config.server.fips_local_rendezvous_addr = Some(rendezvous_addr);
615        config.server.enable_fips_udp = true;
616        config.server.enable_fips_webrtc = false;
617        config.server.fips_request_timeout_ms = 2_000;
618        config.server.fips_peers = vec![crate::config::ConfiguredFipsPeer {
619            npub: remote_endpoint.local_peer_id.clone(),
620            udp_addresses: vec![remote_addr],
621        }];
622        config.nostr.enabled = true;
623        config.nostr.event_transport = NostrEventTransport::FipsLocalOnly;
624        config.nostr.decentralized_pubsub = true;
625
626        let cache = new_daemon_nostr_cache(store.store_arc());
627        let daemon = start_daemon_fips_transport(&config, &daemon_keys, store, Vec::new())
628            .await
629            .unwrap()
630            .expect("daemon FIPS endpoint");
631        let provider =
632            start_daemon_nostr_provider(&config, Some(&daemon), Some(Arc::clone(&cache)))
633                .await
634                .unwrap()
635                .expect("FIPS root provider");
636        assert_eq!(provider.mode(), PubsubProviderMode::Router);
637        let decentralized =
638            start_daemon_nostr_pubsub(&config, Some(&daemon), Some(relay.clone()), cache)
639                .await
640                .unwrap()
641                .expect("decentralized pubsub");
642
643        set_fips_peer_configs(
644            remote_endpoint.native_endpoint.as_ref(),
645            vec![FipsPeerConfig {
646                npub: daemon.endpoint_npub.clone(),
647                udp_addresses: vec![daemon_addr],
648            }],
649        )
650        .await
651        .unwrap();
652        let remote_client = Arc::new(
653            FipsPubsubClient::start(
654                remote_endpoint.native_endpoint.clone(),
655                FipsPubsubClientOptions {
656                    query_timeout: Duration::from_secs(2),
657                    ..Default::default()
658                },
659            )
660            .await
661            .unwrap(),
662        );
663
664        wait_for_native_peer(&daemon.endpoint, &remote_endpoint.local_peer_id).await;
665        wait_for_peer(&remote_endpoint, &daemon.endpoint_npub).await;
666        timeout(Duration::from_secs(5), async {
667            while remote_client.peer_subscription_count().unwrap() == 0 {
668                tokio::time::sleep(Duration::from_millis(20)).await;
669            }
670        })
671        .await
672        .expect("daemon did not subscribe to the authenticated peer");
673
674        // FipsLocalOnly root publication and resolution use the same client
675        // that the decentralized relay bridge already owns.
676        let published_root = root_event(&outbound_keys, "published-root", "aa");
677        let mut published_root_rx = remote_client
678            .subscribe(vec![Filter::new().id(published_root.id)])
679            .await
680            .unwrap();
681        provider
682            .publish(
683                VerifiedEvent::try_from(published_root.clone()).unwrap(),
684                EventSource::local_index("root-publisher"),
685            )
686            .await
687            .unwrap();
688        let delivered_root = timeout(Duration::from_secs(5), published_root_rx.recv())
689            .await
690            .unwrap()
691            .expect("published root delivery");
692        assert_eq!(delivered_root.event.as_event().id, published_root.id);
693
694        let resolved_root = root_event(&trusted_keys, "resolved-root", "bb");
695        remote_client
696            .publish(
697                VerifiedEvent::try_from(resolved_root.clone()).unwrap(),
698                EventSource::local_index("remote-root"),
699            )
700            .await
701            .unwrap();
702        let resolution = provider
703            .query(
704                vec![Filter::new().id(resolved_root.id)],
705                QueryOptions { limit: Some(1) },
706            )
707            .await
708            .unwrap();
709        assert_eq!(resolution.events.len(), 1);
710        assert_eq!(resolution.events[0].event.as_event().id, resolved_root.id);
711
712        // The normal Nostr relay policy remains authoritative after wire-level
713        // signature verification: an untrusted author is not indexed, while a
714        // later trusted event proves the receive path advanced past it.
715        let rejected = EventBuilder::new(Kind::TextNote, "untrusted")
716            .sign_with_keys(&untrusted_keys)
717            .unwrap();
718        remote_client
719            .publish(
720                VerifiedEvent::try_from(rejected.clone()).unwrap(),
721                EventSource::local_index("remote-untrusted"),
722            )
723            .await
724            .unwrap();
725        let accepted = EventBuilder::new(Kind::TextNote, "trusted")
726            .sign_with_keys(&trusted_keys)
727            .unwrap();
728        remote_client
729            .publish(
730                VerifiedEvent::try_from(accepted.clone()).unwrap(),
731                EventSource::local_index("remote-trusted"),
732            )
733            .await
734            .unwrap();
735        wait_for_relay_event(&relay, accepted.id).await;
736        assert!(relay
737            .query_events(&Filter::new().id(rejected.id), 1)
738            .await
739            .is_empty());
740
741        // A trusted local relay publication travels back over the same FIPS
742        // event bus without opening another service receiver.
743        let outbound = EventBuilder::new(Kind::TextNote, "relay outbound")
744            .sign_with_keys(&outbound_keys)
745            .unwrap();
746        let mut outbound_rx = remote_client
747            .subscribe(vec![Filter::new().id(outbound.id)])
748            .await
749            .unwrap();
750        let (client_tx, _client_rx) = mpsc::unbounded_channel();
751        relay.register_client(1, client_tx, None).await;
752        relay
753            .handle_client_message(1, nostr::ClientMessage::event(outbound.clone()))
754            .await;
755        let delivered = timeout(Duration::from_secs(5), outbound_rx.recv())
756            .await
757            .unwrap()
758            .expect("outbound relay publication");
759        assert_eq!(delivered.event.as_event().id, outbound.id);
760
761        decentralized.shutdown();
762        daemon.shutdown().await;
763        remote_endpoint.native_endpoint.shutdown().await.unwrap();
764    }
765
766    #[tokio::test]
767    async fn daemon_blob_resolver_advertises_and_serves_storage_router() {
768        let provider_endpoint = local_only_endpoint("htree-provider-test").await;
769        let observer_endpoint = local_only_endpoint("iris-drive-observer-test").await;
770        let temp = tempfile::tempdir().unwrap();
771        let store = Arc::new(HashtreeStore::new(temp.path()).unwrap());
772        let data = b"served directly from htree StorageRouter".to_vec();
773        let hash = Sha256::digest(&data).into();
774        store.store_arc().put(hash, data.clone()).await.unwrap();
775        let (blob_resolver, provider) = bind_daemon_blob_resolver(
776            &provider_endpoint,
777            store.store_arc(),
778            &[],
779            Duration::from_secs(1),
780        )
781        .await
782        .expect("bind htree provider");
783        let handle = DaemonFipsHandle {
784            endpoint: provider_endpoint.native_endpoint.clone(),
785            endpoint_npub: provider_endpoint.local_peer_id.clone(),
786            discovery_scope: provider_endpoint.discovery_scope.clone(),
787            pubsub_client: None,
788            blob_resolver,
789            blob_transport: Mutex::new(Some(provider)),
790        };
791
792        timeout(Duration::from_secs(10), async {
793            loop {
794                if provider_advertised(&observer_endpoint, &provider_endpoint.local_peer_id) {
795                    break;
796                }
797                tokio::time::sleep(Duration::from_millis(20)).await;
798            }
799        })
800        .await
801        .expect("htree provider capability did not converge");
802
803        let observer_store = Arc::new(hashtree_core::MemoryStore::new());
804        let observer_transport = Arc::new(
805            TcpBlobTransport::bind_route(
806                observer_endpoint.native_endpoint.clone(),
807                observer_store.clone(),
808                Arc::new(StoreBlobRoute::new(observer_store.clone())),
809            )
810            .await
811            .unwrap(),
812        );
813        let observer_fips_route = FipsBlobRoute::discovered(
814            observer_endpoint.native_endpoint.clone(),
815            observer_transport.clone(),
816            4,
817        )
818        .unwrap();
819        let observer_router = BlobRouter::new(
820            vec![BlobRouteEntry::new(
821                "same-host-fips",
822                Arc::new(observer_fips_route),
823            )],
824            Some(observer_store),
825            BlobRouterConfig::default(),
826        )
827        .unwrap();
828        assert_eq!(observer_router.get(&hash, None).await.unwrap(), Some(data));
829
830        drop(observer_router);
831        drop(observer_transport);
832        handle.shutdown().await;
833        timeout(Duration::from_secs(10), async {
834            while provider_advertised(&observer_endpoint, &provider_endpoint.local_peer_id) {
835                tokio::time::sleep(Duration::from_millis(20)).await;
836            }
837        })
838        .await
839        .expect("htree provider capability survived daemon shutdown");
840        drop(handle);
841        observer_endpoint.native_endpoint.shutdown().await.unwrap();
842    }
843
844    #[tokio::test]
845    async fn daemon_mesh_forwarding_observes_two_one_zero_and_exhaustion() {
846        let scope = format!("htree-inbound-forwarding-{}", uuid::Uuid::new_v4());
847        let (upstream_endpoint, upstream_addr) = udp_endpoint(&scope).await;
848        let (provider_endpoint, provider_addr) = udp_endpoint(&scope).await;
849        let (observer_endpoint, observer_addr) = udp_endpoint(&scope).await;
850        let temp = tempfile::tempdir().unwrap();
851        let provider_store = Arc::new(HashtreeStore::new(temp.path().join("provider")).unwrap());
852        let upstream_store = Arc::new(hashtree_core::MemoryStore::new());
853        let data = b"served through the daemon's configured FIPS peer".to_vec();
854        let hash = Sha256::digest(&data).into();
855        upstream_store.put(hash, data.clone()).await.unwrap();
856        let upstream_requests = Arc::new(std::sync::Mutex::new(Vec::new()));
857        let upstream_transport = Arc::new(
858            TcpBlobTransport::bind_route(
859                upstream_endpoint.native_endpoint.clone(),
860                upstream_store.clone(),
861                Arc::new(RecordingStoreRoute {
862                    store: upstream_store,
863                    requests: upstream_requests.clone(),
864                }),
865            )
866            .await
867            .unwrap(),
868        );
869        let upstream_peer = FipsPeerConfig {
870            npub: upstream_endpoint.local_peer_id.clone(),
871            udp_addresses: vec![upstream_addr],
872        };
873        set_fips_peer_configs(
874            provider_endpoint.native_endpoint.as_ref(),
875            vec![
876                upstream_peer.clone(),
877                FipsPeerConfig {
878                    npub: observer_endpoint.local_peer_id.clone(),
879                    udp_addresses: vec![observer_addr],
880                },
881            ],
882        )
883        .await
884        .unwrap();
885        set_fips_peer_configs(
886            upstream_endpoint.native_endpoint.as_ref(),
887            vec![FipsPeerConfig {
888                npub: provider_endpoint.local_peer_id.clone(),
889                udp_addresses: vec![provider_addr.clone()],
890            }],
891        )
892        .await
893        .unwrap();
894        set_fips_peer_configs(
895            observer_endpoint.native_endpoint.as_ref(),
896            vec![FipsPeerConfig {
897                npub: provider_endpoint.local_peer_id.clone(),
898                udp_addresses: vec![provider_addr],
899            }],
900        )
901        .await
902        .unwrap();
903        let (provider_resolver, provider_transport) = bind_daemon_blob_resolver(
904            &provider_endpoint,
905            provider_store.store_arc(),
906            &[upstream_peer],
907            Duration::from_secs(2),
908        )
909        .await
910        .unwrap();
911        let observer_store = Arc::new(hashtree_core::MemoryStore::new());
912        let observer_transport = Arc::new(
913            TcpBlobTransport::bind_route(
914                observer_endpoint.native_endpoint.clone(),
915                observer_store.clone(),
916                Arc::new(StoreBlobRoute::new(observer_store)),
917            )
918            .await
919            .unwrap(),
920        );
921        let observer_fips_route = FipsBlobRoute::explicit(
922            observer_transport.clone(),
923            vec![PeerIdentity::from_npub(&provider_endpoint.local_peer_id).unwrap()],
924            1,
925        )
926        .unwrap();
927        let observer_route = MeshForwardingRoute::new(Arc::new(observer_fips_route));
928
929        wait_for_native_peer(
930            provider_endpoint.native_endpoint.as_ref(),
931            &upstream_endpoint.local_peer_id,
932        )
933        .await;
934        wait_for_native_peer(
935            observer_endpoint.native_endpoint.as_ref(),
936            &provider_endpoint.local_peer_id,
937        )
938        .await;
939        assert_eq!(
940            observer_route
941                .route(hashtree_core::BlobRequest { hash, htl: 0 })
942                .await
943                .unwrap(),
944            hashtree_core::BlobReply::NoResult,
945        );
946        assert!(upstream_requests.lock().unwrap().is_empty());
947        assert_eq!(
948            observer_route
949                .route(hashtree_core::BlobRequest { hash, htl: 1 })
950                .await
951                .unwrap(),
952            hashtree_core::BlobReply::NoResult,
953        );
954        assert!(
955            upstream_requests.lock().unwrap().is_empty(),
956            "HTL 1 reaches the intermediate daemon as 0 and cannot be forwarded"
957        );
958        assert_eq!(
959            observer_route
960                .route(hashtree_core::BlobRequest { hash, htl: 2 })
961                .await
962                .unwrap(),
963            hashtree_core::BlobReply::Data(data),
964        );
965        assert_eq!(
966            upstream_requests.lock().unwrap().as_slice(),
967            &[hashtree_core::BlobRequest { hash, htl: 0 }],
968            "three Hashtree nodes must observe the two-to-one-to-zero forwarding budget while each FIPS carrier preserves its request",
969        );
970
971        drop(observer_route);
972        drop(observer_transport);
973        drop(provider_resolver);
974        drop(provider_transport);
975        drop(upstream_transport);
976        observer_endpoint.native_endpoint.shutdown().await.unwrap();
977        provider_endpoint.native_endpoint.shutdown().await.unwrap();
978        upstream_endpoint.native_endpoint.shutdown().await.unwrap();
979    }
980
981    fn provider_advertised(endpoint: &BoundFipsEndpoint, npub: &str) -> bool {
982        endpoint
983            .native_endpoint
984            .local_instance_advertisements()
985            .unwrap()
986            .iter()
987            .any(|advert| {
988                advert.npub == npub
989                    && advert
990                        .capability(hashtree_fips_transport::TCP_BLOB_CAPABILITY)
991                        .and_then(|capability| capability.fsp_port)
992                        == Some(hashtree_fips_transport::TCP_BLOB_SERVICE_PORT)
993            })
994    }
995
996    async fn local_only_endpoint(scope: &str) -> hashtree_fips_transport::BoundFipsEndpoint {
997        let keys = nostr::Keys::generate();
998        let mut options = FipsEndpointOptions::new(keys.secret_key().to_bech32().unwrap());
999        options.discovery_scope = scope.to_string();
1000        options.enable_udp = false;
1001        options.enable_webrtc = false;
1002        options.enable_local_rendezvous = true;
1003        options.enable_lan_discovery = false;
1004        options.share_local_candidates = false;
1005        bind_fips_endpoint(options).await.unwrap()
1006    }
1007
1008    async fn udp_endpoint(scope: &str) -> (BoundFipsEndpoint, String) {
1009        let addr = reserve_udp_addr();
1010        let keys = nostr::Keys::generate();
1011        let mut options = FipsEndpointOptions::new(keys.secret_key().to_bech32().unwrap());
1012        options.discovery_scope = scope.to_string();
1013        options.enable_udp = true;
1014        options.udp_bind_addr = Some(addr.clone());
1015        options.enable_webrtc = false;
1016        options.enable_local_rendezvous = false;
1017        options.enable_lan_discovery = false;
1018        options.share_local_candidates = false;
1019        let endpoint = bind_fips_endpoint(options).await.unwrap();
1020        (endpoint, addr)
1021    }
1022
1023    fn reserve_udp_addr() -> String {
1024        let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
1025        socket.local_addr().unwrap().to_string()
1026    }
1027
1028    #[cfg(feature = "experimental-decentralized-pubsub")]
1029    async fn wait_for_peer(endpoint: &BoundFipsEndpoint, npub: &str) {
1030        wait_for_native_peer(endpoint.native_endpoint.as_ref(), npub).await;
1031    }
1032
1033    async fn wait_for_native_peer(endpoint: &FipsEndpoint, npub: &str) {
1034        let result = timeout(Duration::from_secs(10), async {
1035            loop {
1036                if endpoint
1037                    .peers()
1038                    .await
1039                    .unwrap()
1040                    .iter()
1041                    .any(|peer| peer.npub == npub && peer.connected)
1042                {
1043                    break;
1044                }
1045                tokio::time::sleep(Duration::from_millis(20)).await;
1046            }
1047        })
1048        .await;
1049        if result.is_err() {
1050            panic!(
1051                "FIPS peer {npub} did not connect; peers={:?}",
1052                endpoint.peers().await
1053            );
1054        }
1055    }
1056
1057    #[cfg(feature = "experimental-decentralized-pubsub")]
1058    async fn trusted_test_relay(
1059        root: &std::path::Path,
1060        allowed: &[nostr::PublicKey],
1061    ) -> Arc<NostrRelay> {
1062        let graph_dir = root.join("graph");
1063        let data_dir = root.join("nostr");
1064        std::fs::create_dir_all(&data_dir).unwrap();
1065        let graph = {
1066            let _guard = crate::socialgraph::test_lock().await;
1067            crate::socialgraph::open_test_social_graph_store_with_mapsize(
1068                &graph_dir,
1069                Some(128 * 1024 * 1024),
1070            )
1071            .unwrap()
1072        };
1073        let backend: Arc<dyn crate::socialgraph::SocialGraphBackend> = graph;
1074        let allowed = allowed
1075            .iter()
1076            .map(|pubkey| pubkey.to_hex())
1077            .collect::<HashSet<_>>();
1078        let access = Arc::new(crate::socialgraph::SocialGraphAccessControl::new(
1079            Arc::clone(&backend),
1080            0,
1081            allowed.clone(),
1082        ));
1083        Arc::new(
1084            NostrRelay::new(
1085                backend,
1086                data_dir,
1087                allowed,
1088                Some(access),
1089                crate::nostr_relay::NostrRelayConfig {
1090                    spambox_db_max_bytes: 0,
1091                    ..Default::default()
1092                },
1093            )
1094            .unwrap(),
1095        )
1096    }
1097
1098    #[cfg(feature = "experimental-decentralized-pubsub")]
1099    fn root_event(keys: &nostr::Keys, name: &str, hash: &str) -> nostr::Event {
1100        EventBuilder::new(Kind::Custom(30_078), hash)
1101            .tags([
1102                Tag::parse(["d", name]).unwrap(),
1103                Tag::parse(["l", "hashtree"]).unwrap(),
1104                Tag::parse(["hash", hash]).unwrap(),
1105            ])
1106            .sign_with_keys(keys)
1107            .unwrap()
1108    }
1109
1110    #[cfg(feature = "experimental-decentralized-pubsub")]
1111    async fn wait_for_relay_event(relay: &NostrRelay, id: nostr::EventId) {
1112        timeout(Duration::from_secs(5), async {
1113            loop {
1114                if relay
1115                    .query_events(&Filter::new().id(id), 1)
1116                    .await
1117                    .iter()
1118                    .any(|event| event.id == id)
1119                {
1120                    break;
1121                }
1122                tokio::time::sleep(Duration::from_millis(20)).await;
1123            }
1124        })
1125        .await
1126        .expect("trusted decentralized event was not indexed");
1127    }
1128
1129    #[test]
1130    fn daemon_fips_peer_configs_prefer_configured_peers() {
1131        let mut config = Config::default();
1132        config.server.fips_peers = vec![
1133            crate::config::ConfiguredFipsPeer {
1134                npub: " origin ".to_string(),
1135                udp_addresses: vec![" udp:192.0.2.10:2121 ".to_string(), " ".to_string()],
1136            },
1137            crate::config::ConfiguredFipsPeer {
1138                npub: "origin".to_string(),
1139                udp_addresses: vec!["udp:ignored:2121".to_string()],
1140            },
1141        ];
1142
1143        let peers = daemon_fips_peer_configs(
1144            &config,
1145            vec![
1146                "origin".to_string(),
1147                " followed ".to_string(),
1148                " ".to_string(),
1149            ],
1150        );
1151
1152        assert_eq!(
1153            peers,
1154            vec![
1155                FipsPeerConfig {
1156                    npub: "origin".to_string(),
1157                    udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
1158                },
1159                FipsPeerConfig::new("followed"),
1160            ]
1161        );
1162    }
1163
1164    #[test]
1165    fn blob_carrier_deadline_outlives_the_complete_resolver_budget() {
1166        for resolver_timeout in [Duration::from_millis(1), Duration::from_secs(20)] {
1167            assert!(blob_transport_config(resolver_timeout).idle_timeout > resolver_timeout);
1168        }
1169    }
1170
1171    #[cfg(feature = "experimental-decentralized-pubsub")]
1172    #[test]
1173    fn daemon_decentralized_pubsub_requires_config_fips_and_relay() {
1174        let mut config = Config::default();
1175        assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
1176
1177        config.nostr.decentralized_pubsub = true;
1178        assert!(daemon_decentralized_pubsub_ready(&config, true, true));
1179        assert!(!daemon_decentralized_pubsub_ready(&config, false, true));
1180        assert!(!daemon_decentralized_pubsub_ready(&config, true, false));
1181
1182        config.nostr.enabled = false;
1183        assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
1184    }
1185}