Skip to main content

nym_sdk_session/
session.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! The provisioning session: mnemonic → issued ticketbooks → registered gateways.
5//!
6//! The session runs a [`BandwidthController`] event loop (the single writer to the credential
7//! store) and performs all ticket spending through its [`BandwidthControllerRequestSender`], which
8//! implements [`BandwidthTicketProvider`]. Chain-side restock (depositing NYM for new ticketbooks)
9//! is off by default and opted into via [`SessionConfig::automatic_topups`]; gateway-side top-up of
10//! a live tunnel spends already-stored tickets and is driven by the datapath, not here.
11
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::Duration;
15
16use nym_bandwidth_controller::config::BandwidthControllerConfig;
17use nym_bandwidth_controller::requests::BandwidthControllerRequestSender;
18use nym_bandwidth_controller::{BandwidthController, BandwidthTicketProvider, TicketType};
19use nym_bandwidth_fetcher::NyxdCredentialFetcher;
20use nym_credentials_interface::BandwidthCredential;
21use nym_crypto::asymmetric::{ed25519, x25519};
22use nym_lp::peer::{DHKeyPair, LpRemotePeer};
23use nym_network_defaults::NymNetworkDetails;
24use nym_registration_client::{LpRegistrationClient, NestedLpSession};
25use nym_registration_common::WireguardConfiguration;
26use nym_task::ShutdownToken;
27use nym_validator_client::nym_api::NymApiClientExt;
28use nym_validator_client::DirectSigningHttpRpcNyxdClient;
29use rand09::SeedableRng;
30use sha2::{Digest, Sha256};
31use time::OffsetDateTime;
32use tokio::net::TcpStream;
33use tokio::task::JoinHandle;
34use tokio_util::sync::CancellationToken;
35use url::Url;
36use zeroize::Zeroizing;
37
38use nym_api_requests::models::described::v2::NymNodeDescriptionV2;
39
40use crate::config::{RestockPolicy, SessionConfig};
41use crate::dvpn::{DvpnDirectory, QuicBridge};
42use crate::error::SessionError;
43use crate::fetcher::TimeoutFetcher;
44use crate::gateway::{self, GatewayInfo, GatewaySpec, SelectedGateway, WgRole};
45use crate::registration_cache::RegistrationCache;
46
47/// Number of tickets to reserve when checking for / spending a stored ticketbook.
48const TICKETS_TO_SPEND: u32 = 1;
49/// Timeout for nym-api requests.
50const API_TIMEOUT: Duration = Duration::from_secs(30);
51/// Overall budget for `ensure_ticketbooks` (deposit + issuance + signing-data fetches). Generous —
52/// it backstops *unforeseen* stalls; the per-fetch bounds live in [`TimeoutFetcher`].
53const PROVISIONING_TIMEOUT: Duration = Duration::from_secs(5 * 60);
54
55/// The WireGuard ticket types a dVPN session ever provisions. Never includes mixnet types, so a
56/// session can never deposit for bandwidth it does not use.
57fn wireguard_ticket_types() -> Vec<TicketType> {
58    vec![TicketType::V1WireguardEntry, TicketType::V1WireguardExit]
59}
60
61/// The ticket types needed for a given tunnel shape.
62fn needed_ticket_types(two_hop: bool) -> Vec<TicketType> {
63    let mut types = vec![TicketType::V1WireguardEntry];
64    if two_hop {
65        types.push(TicketType::V1WireguardExit);
66    }
67    types
68}
69
70/// Everything the datapath needs to bring up ONE WireGuard hop.
71pub struct HopConfig {
72    /// Gateway-returned WireGuard configuration (pubkey, PSK, endpoint, IPs).
73    pub wg_config: WireguardConfiguration,
74    /// The client WireGuard private key generated for this hop.
75    pub client_private_key: x25519::PrivateKey,
76    /// The gateway's ed25519 identity.
77    pub gateway_identity: ed25519::PublicKey,
78    /// Directory metadata for this hop's gateway (identity, node id, country, IP).
79    pub gateway: GatewayInfo,
80    /// QUIC bridge params for this hop, set only for a QUIC entry hop (see
81    /// [`Session::register_two_hop_quic`]); `None` for direct/exit hops.
82    pub bridge: Option<QuicBridge>,
83}
84
85/// The result of registering a tunnel: one hop for single-hop, two for two-hop.
86pub struct Registration {
87    /// Entry (or sole) hop.
88    pub entry: HopConfig,
89    /// Exit hop; `None` for single-hop tunnels.
90    pub exit: Option<HopConfig>,
91}
92
93/// A session-owned, running bandwidth controller: the request sender used for spending/provisioning
94/// and the join handle for the background event loop.
95struct OwnedController {
96    sender: BandwidthControllerRequestSender,
97    task: JoinHandle<()>,
98}
99
100/// Provisioning facade over the credential + registration machinery.
101pub struct Session {
102    api: nym_http_api_client::Client,
103    /// Bandwidth provider used for all ticket spending (registration + gateway top-up). Either the
104    /// session's own controller sender or a caller-supplied external provider.
105    provider: Arc<dyn BandwidthTicketProvider>,
106    /// Present when the session spawned its own controller; drives provisioning and shutdown.
107    owned: Option<OwnedController>,
108    cancel: CancellationToken,
109    /// dVPN gateway directory (empty if none configured or the fetch failed).
110    directory: Option<DvpnDirectory>,
111    /// Persistent per-gateway registration cache; `None` when reuse is disabled (then nothing is
112    /// read from nor written to disk). Guarded by a std mutex — held only across synchronous
113    /// lookup/insert calls, never across an await.
114    reg_cache: Option<std::sync::Mutex<RegistrationCache>>,
115}
116
117impl Session {
118    /// Build a session. Unless an external `bandwidth_provider` is supplied, this connects the
119    /// signing chain client, opens the credential store, wires the bandwidth controller + credential
120    /// fetcher (scoped to WireGuard ticket types), and spawns the controller event loop.
121    pub async fn new(
122        config: SessionConfig,
123        cancel: CancellationToken,
124    ) -> Result<Self, SessionError> {
125        let SessionConfig {
126            mnemonic,
127            network,
128            credential_store_path,
129            data_path,
130            dvpn_directory_url,
131            automatic_topups,
132            bandwidth_provider,
133            reuse_registrations,
134        } = config;
135
136        // Registration reuse: load the per-network cache from the data directory (before
137        // `network`/`data_path` are moved into the controller). Disabled => no cache at all.
138        let reg_cache = reuse_registrations.then(|| {
139            std::sync::Mutex::new(RegistrationCache::load(
140                &data_path,
141                network.network_name.clone(),
142            ))
143        });
144
145        let api_url_str = network
146            .endpoints
147            .iter()
148            .find_map(|e| e.api_url.clone())
149            .ok_or(SessionError::MissingEndpoint { which: "nym-api" })?;
150        let api_url = Url::parse(&api_url_str).map_err(|source| SessionError::InvalidUrl {
151            which: "nym-api",
152            url: api_url_str.clone(),
153            source,
154        })?;
155        let api = nym_http_api_client::Client::new(api_url, Some(API_TIMEOUT));
156
157        // Best-effort dVPN directory (monikers + QUIC bridge params).
158        let directory = match dvpn_directory_url {
159            Some(url) => match DvpnDirectory::fetch(&url).await {
160                Ok(dir) => Some(dir),
161                Err(e) => {
162                    tracing::warn!("failed to fetch dVPN directory at {url}: {e}");
163                    Some(DvpnDirectory::default())
164                }
165            },
166            None => None,
167        };
168
169        // Bandwidth provider: an external one (caller runs its own controller) or our own.
170        let (provider, owned) = match bandwidth_provider {
171            Some(external) => (external, None),
172            None => {
173                let (provider, owned) = Self::spawn_controller(
174                    mnemonic,
175                    network,
176                    credential_store_path,
177                    data_path,
178                    automatic_topups,
179                    cancel.clone(),
180                )
181                .await?;
182                (provider, Some(owned))
183            }
184        };
185
186        Ok(Self {
187            api,
188            provider,
189            owned,
190            cancel,
191            directory,
192            reg_cache,
193        })
194    }
195
196    /// Build the nyxd client + credential store + scoped fetcher, then spawn the controller loop.
197    async fn spawn_controller(
198        mnemonic: bip39::Mnemonic,
199        network: NymNetworkDetails,
200        credential_store_path: Option<PathBuf>,
201        data_path: PathBuf,
202        automatic_topups: Option<RestockPolicy>,
203        cancel: CancellationToken,
204    ) -> Result<(Arc<dyn BandwidthTicketProvider>, OwnedController), SessionError> {
205        let nyxd_url = network
206            .endpoints
207            .first()
208            .map(|e| e.nyxd_url.clone())
209            .ok_or(SessionError::MissingEndpoint { which: "nyxd" })?;
210
211        // Derive a stable, non-reversible client id from the mnemonic entropy BEFORE moving the
212        // mnemonic into the chain client (so we neither clone it nor hand the raw entropy on).
213        let client_id = derive_client_id(&mnemonic);
214
215        // Direct-signing chain client from the mnemonic (consumes it — no clone).
216        let nyxd = DirectSigningHttpRpcNyxdClient::connect_with_mnemonic_and_network_details(
217            nyxd_url.as_str(),
218            network,
219            mnemonic,
220        )?;
221        let nyxd = Arc::new(nyxd);
222
223        // Persistent credential store (survives bring-down / bring-up).
224        let store_path = credential_store_path.unwrap_or_else(|| data_path.join("credentials.db"));
225        if let Some(parent) = store_path.parent() {
226            std::fs::create_dir_all(parent).map_err(|e| SessionError::Storage(e.to_string()))?;
227        }
228        let storage = nym_credential_storage::initialise_persistent_storage(&store_path).await;
229
230        // Credential fetcher: deposits NYM and aggregates issued wallets. Wrapped so the
231        // read-only global-signing-data fetches are time-bounded: unresponsive ecash signers
232        // (a permanent fact of the distributed deployment) must yield a fast fetch error —
233        // which the controller's best-effort store path tolerates, persisting the paid-for
234        // ticketbook anyway — rather than hanging the controller loop and losing the book.
235        // Issuance itself (the deposit) is deliberately not timed; see `TimeoutFetcher` docs.
236        let fetcher_db = data_path.join("fetcher-requests.db");
237        let fetcher = NyxdCredentialFetcher::new(nyxd, &fetcher_db, client_id)
238            .await
239            .map_err(|e| SessionError::Issuance(e.to_string()))?;
240        let fetcher = TimeoutFetcher::new(fetcher);
241
242        // The controller only ever proactively restocks (and thus deposits for) the types in
243        // `managed_ticket_types`. Opt-in automatic top-up manages the WireGuard types (with the
244        // caller's thresholds); the default leaves it empty, so the session provisions on demand
245        // (via `ensure_ticketbooks`) but the controller never deposits in the background — while the
246        // fetcher stays installed so it can serve those on-demand fetches and the global signing
247        // data needed to spend. Either way, mixnet types are never in the managed set, so the
248        // session can never deposit for mixnet bandwidth.
249        let config = match automatic_topups {
250            Some(policy) => {
251                let mut config: BandwidthControllerConfig = policy.into();
252                config.managed_ticket_types = wireguard_ticket_types();
253                config
254            }
255            None => BandwidthControllerConfig {
256                managed_ticket_types: Vec::new(),
257                ..Default::default()
258            },
259        };
260        let controller = BandwidthController::new(storage)
261            .with_config(config)
262            .with_credential_fetcher(fetcher);
263
264        let sender = controller.get_request_sender();
265        let shutdown = ShutdownToken::new_from_tokio_token(cancel.clone());
266        let task = tokio::spawn(async move { controller.run(shutdown).await });
267
268        let provider: Arc<dyn BandwidthTicketProvider> = Arc::new(sender.clone());
269        Ok((provider, OwnedController { sender, task }))
270    }
271
272    /// Ensure the WireGuard ticketbooks needed for the tunnel shape are stored, issuing (and
273    /// depositing) only when no usable ticketbook of a required type is already stored.
274    ///
275    /// With an external bandwidth provider this is a no-op — the caller provisions.
276    pub async fn ensure_ticketbooks(&self, two_hop: bool) -> Result<(), SessionError> {
277        self.ensure_ticket_types(needed_ticket_types(two_hop)).await
278    }
279
280    /// [`ensure_ticketbooks`](Self::ensure_ticketbooks) scoped to exactly `types` — used by the
281    /// registration paths to provision only for hops that actually need a fresh (ticket-spending)
282    /// registration. An empty `types` is a no-op, so a fully cache-served registration never
283    /// triggers ticketbook provisioning (or its deposits).
284    async fn ensure_ticket_types(&self, types: Vec<TicketType>) -> Result<(), SessionError> {
285        if types.is_empty() {
286            return Ok(());
287        }
288        let Some(owned) = &self.owned else {
289            // external provider: the caller is responsible for provisioning
290            return Ok(());
291        };
292        // Explicit restock request (works regardless of the auto-restock setting) scoped to exactly
293        // the needed WireGuard types, then wait until they are usable. Race cancellation so a
294        // caller that cancels mid-wait gets a prompt `Cancelled` instead of blocking; this is
295        // funds-safe because the deposit itself runs in the controller task and any interrupted
296        // issuance is recovered from the fetcher's pending-request store on a later fetch.
297        //
298        // The work arm is additionally bounded by an overall budget (defense in depth over the
299        // per-fetch `TimeoutFetcher` bounds): whatever else might stall, provisioning surfaces a
300        // `ProvisioningTimeout` naming unresponsive signers as the likely cause instead of
301        // blocking forever. Interrupting the wait is funds-safe for the same reason as above.
302        tokio::select! {
303            biased;
304            _ = self.cancel.cancelled() => Err(SessionError::Cancelled),
305            res = tokio::time::timeout(PROVISIONING_TIMEOUT, async {
306                owned
307                    .sender
308                    .restock_ticketbooks(types.clone())
309                    .await
310                    .map_err(|e| SessionError::Issuance(e.to_string()))?;
311                owned
312                    .sender
313                    .wait_for_ticketbooks(types)
314                    .await
315                    .map_err(|e| SessionError::Issuance(e.to_string()))
316            }) => res.unwrap_or(Err(SessionError::ProvisioningTimeout {
317                after: PROVISIONING_TIMEOUT,
318            })),
319        }
320    }
321
322    /// Obtain a spendable bandwidth credential for `gateway_id` by spending one
323    /// stored WireGuard ticket. Feeds the gateway `metadata` endpoint's
324    /// `topup_bandwidth` so a long-lived tunnel can extend its bandwidth.
325    pub async fn obtain_wireguard_credential(
326        &self,
327        gateway_id: ed25519::PublicKey,
328        role: WgRole,
329    ) -> Result<BandwidthCredential, SessionError> {
330        let ticket_type = match role {
331            WgRole::Entry => TicketType::V1WireguardEntry,
332            WgRole::Exit => TicketType::V1WireguardExit,
333        };
334        let prepared = self
335            .provider
336            .get_ecash_ticket(
337                ticket_type,
338                gateway_id,
339                TICKETS_TO_SPEND,
340                OffsetDateTime::now_utc(),
341            )
342            .await
343            .map_err(|e| SessionError::Issuance(e.to_string()))?
344            .ok_or_else(|| {
345                SessionError::Issuance("no stored ticket available for top-up".into())
346            })?;
347        Ok(BandwidthCredential::from(prepared.data))
348    }
349
350    /// The bandwidth provider used for ticket spending. Hand this to the datapath so a live tunnel
351    /// can top up from stored tickets.
352    pub fn bandwidth_provider(&self) -> Arc<dyn BandwidthTicketProvider> {
353        self.provider.clone()
354    }
355
356    /// Fetch the current described-node topology once.
357    async fn fetch_topology(&self) -> Result<Vec<NymNodeDescriptionV2>, SessionError> {
358        Ok(self.api.get_all_described_nodes_v2().await?)
359    }
360
361    /// Fetch the topology, racing the cancellation token. Safe to abort: no ticket is spent during
362    /// selection, so registration callers use this for the pre-spend phase and then run the
363    /// (ticket-spending) exchange without racing cancel.
364    async fn fetch_topology_cancellable(&self) -> Result<Vec<NymNodeDescriptionV2>, SessionError> {
365        tokio::select! {
366            biased;
367            _ = self.cancel.cancelled() => Err(SessionError::Cancelled),
368            res = self.fetch_topology() => res,
369        }
370    }
371
372    /// Select a WireGuard-capable gateway for the given role (fetches topology).
373    pub async fn select_gateway(
374        &self,
375        spec: &GatewaySpec,
376        role: WgRole,
377    ) -> Result<SelectedGateway, SessionError> {
378        tokio::select! {
379            biased;
380            _ = self.cancel.cancelled() => Err(SessionError::Cancelled),
381            res = async {
382                let nodes = self.fetch_topology().await?;
383                gateway::select(&nodes, spec, role, self.directory.as_ref(), false, None)
384            } => res,
385        }
386    }
387
388    /// Register a single-hop tunnel against one gateway via the LP
389    /// single-gateway `register_dvpn` path (spends a `V1WireguardEntry` ticket).
390    pub async fn register_single_hop(
391        &self,
392        gateway: &GatewaySpec,
393    ) -> Result<Registration, SessionError> {
394        self.register_single_inner(gateway).await
395    }
396
397    /// Run `f` over the registration cache; `None` when reuse is disabled. The lock is only
398    /// ever held across the synchronous `f` (never an await).
399    fn with_cache<R>(&self, f: impl FnOnce(&mut RegistrationCache) -> R) -> Option<R> {
400        self.reg_cache.as_ref().map(|cache| {
401            f(&mut cache
402                .lock()
403                .unwrap_or_else(|poisoned| poisoned.into_inner()))
404        })
405    }
406
407    /// Look up a cached registration for the gateway with `identity` in `role`; a hit is
408    /// assembled into a [`HopConfig`] carrying the given directory metadata (no gateway
409    /// exchange, no ticket spent) and logged so the zero-spend behavior is auditable. Always
410    /// `None` when reuse is disabled.
411    fn cached_hop(
412        &self,
413        identity: &ed25519::PublicKey,
414        gateway: GatewayInfo,
415        role: WgRole,
416    ) -> Option<HopConfig> {
417        let cached = self.with_cache(|cache| cache.lookup(identity, role))??;
418        tracing::info!(
419            "reusing cached registration for {} ({role:?}) — no ticket spent",
420            identity.to_base58_string()
421        );
422        Some(HopConfig {
423            wg_config: cached.wg_config,
424            client_private_key: cached.client_private_key,
425            gateway_identity: *identity,
426            gateway,
427            bridge: None,
428        })
429    }
430
431    /// Persist a fresh registration and assemble its [`HopConfig`] — the shared tail of every
432    /// successful `register_dvpn` exchange. Persisting is a no-op when reuse is disabled
433    /// (nothing is written to disk then — see `SessionConfig::reuse_registrations`).
434    fn finalize_hop(
435        &self,
436        identity: &ed25519::PublicKey,
437        gateway: GatewayInfo,
438        role: WgRole,
439        client_private_key: x25519::PrivateKey,
440        wg_config: WireguardConfiguration,
441    ) -> HopConfig {
442        self.with_cache(|cache| cache.insert(identity, role, &client_private_key, &wg_config));
443        HopConfig {
444            wg_config,
445            client_private_key,
446            gateway_identity: *identity,
447            gateway,
448            bridge: None,
449        }
450    }
451
452    /// Remove a cached registration for (gateway, role) — the fallback path when a reused
453    /// registration fails to establish (see `Tunnel::await_established` in `smoldvpn`):
454    /// invalidate the failed hop(s), then register again for a fresh (ticket-spending) peer.
455    /// A missing entry (or reuse disabled) is a no-op.
456    pub fn invalidate_registration(&self, gateway: &ed25519::PublicKey, role: WgRole) {
457        self.with_cache(|cache| cache.remove(gateway, role));
458    }
459
460    async fn register_single_inner(
461        &self,
462        gateway: &GatewaySpec,
463    ) -> Result<Registration, SessionError> {
464        // Everything up to and including the LP handshake spends no ticket and stays cancellable
465        // (topology fetch here; handshake inside `register_hop`). Only the ticket-spending
466        // `register_dvpn` call runs without racing cancel, so a cancel can't drop the future after
467        // the gateway has processed the spend and lose the ticket.
468        let nodes = self.fetch_topology_cancellable().await?;
469        let selected = gateway::select(
470            &nodes,
471            gateway,
472            WgRole::Entry,
473            self.directory.as_ref(),
474            false,
475            None,
476        )?;
477        // Cache first: a reusable registration needs no ticketbooks and no gateway exchange.
478        if let Some(hop) = self.cached_hop(&selected.identity, selected.info(), WgRole::Entry) {
479            return Ok(Registration {
480                entry: hop,
481                exit: None,
482            });
483        }
484        self.ensure_ticket_types(vec![TicketType::V1WireguardEntry])
485            .await?;
486        let hop = self
487            .register_hop(&selected, TicketType::V1WireguardEntry)
488            .await?;
489        Ok(Registration {
490            entry: hop,
491            exit: None,
492        })
493    }
494
495    /// Register a two-hop tunnel: an outer LP session with the entry gateway,
496    /// the exit registered via entry forwarding, then the entry itself.
497    pub async fn register_two_hop(
498        &self,
499        entry: &GatewaySpec,
500        exit: &GatewaySpec,
501    ) -> Result<Registration, SessionError> {
502        self.register_two_hop_inner(entry, exit, false).await
503    }
504
505    /// Like [`register_two_hop`](Self::register_two_hop), but the ENTRY gateway
506    /// must advertise a QUIC bridge (per the configured dVPN directory). The
507    /// returned `entry` hop carries its [`QuicBridge`] in `bridge`. Fails with
508    /// [`SessionError::NoQuicGateway`] if no QUIC entry matches the spec.
509    /// (QUIC only fronts the two-hop entry leg; the exit is registered normally.)
510    pub async fn register_two_hop_quic(
511        &self,
512        entry: &GatewaySpec,
513        exit: &GatewaySpec,
514    ) -> Result<Registration, SessionError> {
515        self.register_two_hop_inner(entry, exit, true).await
516    }
517
518    async fn register_two_hop_inner(
519        &self,
520        entry: &GatewaySpec,
521        exit: &GatewaySpec,
522        entry_quic: bool,
523    ) -> Result<Registration, SessionError> {
524        // Selection and the LP handshake spend no ticket and stay cancellable (topology fetch here,
525        // handshake below); only the ticket-spending calls (`handshake_and_register_dvpn`,
526        // `register_dvpn`) run without racing the cancel token, so a cancel can't drop the future
527        // mid-spend and lose a ticket. Topology is fetched once.
528        let nodes = self.fetch_topology_cancellable().await?;
529        let entry_gw = gateway::select(
530            &nodes,
531            entry,
532            WgRole::Entry,
533            self.directory.as_ref(),
534            entry_quic,
535            None,
536        )?;
537        // Exclude the entry gateway so a two-hop tunnel never uses one gateway twice.
538        let exit_gw = gateway::select(
539            &nodes,
540            exit,
541            WgRole::Exit,
542            self.directory.as_ref(),
543            false,
544            Some(&entry_gw.identity),
545        )?;
546
547        // The entry hop carries QUIC bridge params only when QUIC was required
548        // (selection guarantees `entry_gw.quic` is `Some` in that case).
549        let entry_bridge = if entry_quic {
550            entry_gw.quic.clone()
551        } else {
552            None
553        };
554
555        // Cache first: each hop may be independently reusable. Only uncached hops need
556        // ticketbooks, an LP session, and a (ticket-spending) registration.
557        // Both hops served from cache: no ticketbooks, no LP exchange at all.
558        let (cached_entry, cached_exit) = match (
559            self.cached_hop(&entry_gw.identity, entry_gw.info(), WgRole::Entry),
560            self.cached_hop(&exit_gw.identity, exit_gw.info(), WgRole::Exit),
561        ) {
562            (Some(mut entry_hop), Some(exit_hop)) => {
563                entry_hop.bridge = entry_bridge;
564                return Ok(Registration {
565                    entry: entry_hop,
566                    exit: Some(exit_hop),
567                });
568            }
569            partial => partial,
570        };
571
572        // Ticketbooks only for the hop(s) that will actually spend.
573        let mut needed = Vec::new();
574        if cached_entry.is_none() {
575            needed.push(TicketType::V1WireguardEntry);
576        }
577        if cached_exit.is_none() {
578            needed.push(TicketType::V1WireguardExit);
579        }
580        self.ensure_ticket_types(needed).await?;
581
582        let entry_lp = lp_info(&entry_gw)?;
583        let exit_lp = lp_info(&exit_gw)?;
584
585        // Outer session with the entry gateway — needed to register either hop (the exit is
586        // registered THROUGH the entry's LP forwarding).
587        let entry_keypair = Arc::new(DHKeyPair::new(&mut rand09::rng()));
588        let entry_peer =
589            LpRemotePeer::new(entry_lp.x25519).with_key_digests(entry_lp.expected_kem_key_hashes);
590        let mut entry_client = LpRegistrationClient::<TcpStream>::new_with_default_config(
591            entry_keypair,
592            entry_peer,
593            entry_lp.address,
594            entry_lp.ciphersuite,
595            entry_lp.lp_protocol_version,
596        );
597        // The LP handshake spends no ticket, so it stays cancellable — otherwise a stalled/
598        // black-holed entry gateway would ignore the cancel token until the OS TCP timeout. Only
599        // the ticket-spending registration calls below run without racing cancel.
600        tokio::select! {
601            biased;
602            _ = self.cancel.cancelled() => return Err(SessionError::Cancelled),
603            r = entry_client.perform_handshake() => r.map_err(|source| SessionError::Registration {
604                address: entry_lp.address,
605                source,
606            })?,
607        }
608
609        let mut rng = rand09::rngs::StdRng::from_os_rng();
610
611        // Exit hop: reuse or register via entry forwarding.
612        let exit_hop = match cached_exit {
613            Some(hop) => hop,
614            None => {
615                let exit_keypair = Arc::new(DHKeyPair::new(&mut rand09::rng()));
616                let exit_peer = LpRemotePeer::new(exit_lp.x25519)
617                    .with_key_digests(exit_lp.expected_kem_key_hashes);
618                let mut nested = NestedLpSession::new(
619                    exit_lp.address,
620                    exit_keypair,
621                    exit_peer,
622                    exit_lp.ciphersuite,
623                    exit_lp.lp_protocol_version,
624                );
625                let exit_wg = x25519::KeyPair::new(&mut rand::thread_rng());
626                let exit_cfg = nested
627                    .handshake_and_register_dvpn::<TcpStream, _>(
628                        &mut entry_client,
629                        &mut rng,
630                        &exit_wg,
631                        &exit_gw.identity,
632                        self.provider.as_ref(),
633                        None,
634                        TicketType::V1WireguardExit,
635                    )
636                    .await
637                    .map_err(|source| SessionError::Registration {
638                        address: exit_lp.address,
639                        source,
640                    })?;
641                self.finalize_hop(
642                    &exit_gw.identity,
643                    exit_gw.info(),
644                    WgRole::Exit,
645                    x25519::PrivateKey::from_secret(exit_wg.private_key().to_bytes()),
646                    exit_cfg,
647                )
648            }
649        };
650
651        // Entry hop: reuse or register on the outer session.
652        let mut entry_hop = match cached_entry {
653            Some(hop) => hop,
654            None => {
655                let entry_wg = x25519::KeyPair::new(&mut rand::thread_rng());
656                let entry_cfg = entry_client
657                    .register_dvpn(
658                        &mut rng,
659                        &entry_wg,
660                        &entry_gw.identity,
661                        self.provider.as_ref(),
662                        None,
663                        TicketType::V1WireguardEntry,
664                    )
665                    .await
666                    .map_err(|source| SessionError::Registration {
667                        address: entry_lp.address,
668                        source,
669                    })?;
670                self.finalize_hop(
671                    &entry_gw.identity,
672                    entry_gw.info(),
673                    WgRole::Entry,
674                    x25519::PrivateKey::from_secret(entry_wg.private_key().to_bytes()),
675                    entry_cfg,
676                )
677            }
678        };
679        entry_hop.bridge = entry_bridge;
680
681        Ok(Registration {
682            entry: entry_hop,
683            exit: Some(exit_hop),
684        })
685    }
686
687    /// Register a single hop against an already-selected gateway.
688    async fn register_hop(
689        &self,
690        selected: &SelectedGateway,
691        ticket_type: TicketType,
692    ) -> Result<HopConfig, SessionError> {
693        let lp = lp_info(selected)?;
694        let keypair = Arc::new(DHKeyPair::new(&mut rand09::rng()));
695        let peer = LpRemotePeer::new(lp.x25519).with_key_digests(lp.expected_kem_key_hashes);
696        let mut client = LpRegistrationClient::<TcpStream>::new_with_default_config(
697            keypair,
698            peer,
699            lp.address,
700            lp.ciphersuite,
701            lp.lp_protocol_version,
702        );
703
704        // The LP handshake spends no ticket, so it stays cancellable (a stalled gateway would
705        // otherwise hang past the cancel token); only the ticket-spending `register_dvpn` below runs
706        // without racing cancel.
707        tokio::select! {
708            biased;
709            _ = self.cancel.cancelled() => return Err(SessionError::Cancelled),
710            r = client.perform_handshake() => r.map_err(|source| SessionError::Registration {
711                address: lp.address,
712                source,
713            })?,
714        }
715
716        let mut rng = rand09::rngs::StdRng::from_os_rng();
717        let wg = x25519::KeyPair::new(&mut rand::thread_rng());
718        let cfg = client
719            .register_dvpn(
720                &mut rng,
721                &wg,
722                &selected.identity,
723                self.provider.as_ref(),
724                None,
725                ticket_type,
726            )
727            .await
728            .map_err(|source| SessionError::Registration {
729                address: lp.address,
730                source,
731            })?;
732
733        let role = match ticket_type {
734            TicketType::V1WireguardExit => WgRole::Exit,
735            _ => WgRole::Entry,
736        };
737        Ok(self.finalize_hop(
738            &selected.identity,
739            selected.info(),
740            role,
741            x25519::PrivateKey::from_secret(wg.private_key().to_bytes()),
742            cfg,
743        ))
744    }
745
746    /// Shut down the session's bandwidth controller (if it owns one), awaiting its cleanup so the
747    /// credential store is closed cleanly. Stored tickets are retained.
748    pub async fn shutdown(mut self) {
749        self.cancel.cancel();
750        if let Some(owned) = self.owned.take() {
751            let _ = owned.task.await;
752        }
753    }
754}
755
756impl Drop for Session {
757    fn drop(&mut self) {
758        // Best-effort: signal the controller to stop if `shutdown()` was not called. The spawned
759        // task observes the cancelled token and cleans up on its own.
760        self.cancel.cancel();
761    }
762}
763
764/// Derive a stable, non-reversible client id from a mnemonic's entropy. Domain-separated so it can
765/// never collide with another use of the same entropy, and hashed so the raw entropy is never
766/// handed to issuance.
767fn derive_client_id(mnemonic: &bip39::Mnemonic) -> Zeroizing<Vec<u8>> {
768    let entropy = Zeroizing::new(mnemonic.to_entropy());
769    let mut hasher = Sha256::new();
770    hasher.update(b"nym-sdk-session::client-id::v1");
771    hasher.update(entropy.as_slice());
772    Zeroizing::new(hasher.finalize().to_vec())
773}
774
775/// Extract the LP info for a selected gateway or fail with a clear error.
776fn lp_info(
777    selected: &SelectedGateway,
778) -> Result<nym_registration_common::NymNodeLPInformation, SessionError> {
779    selected
780        .node
781        .node
782        .lp_data
783        .clone()
784        .ok_or_else(|| SessionError::MalformedGateway {
785            identity: selected.identity.to_base58_string(),
786            reason: "gateway advertises no LP data".to_string(),
787        })
788}
789
790#[cfg(test)]
791#[path = "session_tests.rs"]
792mod tests;