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