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