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