Skip to main content

nym_sdk_session/
gateway.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! Gateway selection and construction of the per-hop `RegistrationNymNode` the
5//! LP registration client consumes.
6
7use std::net::SocketAddr;
8use std::sync::Arc;
9
10use nym_api_requests::models::described::v2::NymNodeDescriptionV2;
11use nym_crypto::asymmetric::{ed25519, x25519};
12use nym_registration_client::RegistrationNymNode;
13use nym_registration_common::{NymNodeInformation, NymNodeLPInformation};
14use rand::seq::SliceRandom;
15
16use crate::dvpn::{DvpnDirectory, QuicBridge};
17use crate::error::SessionError;
18
19/// How the caller names the gateway(s) to use.
20#[derive(Clone, Debug)]
21pub enum GatewaySpec {
22    /// An exact gateway identity (ed25519) key.
23    Identity(ed25519::PublicKey),
24    /// A two-letter ISO 3166 alpha-2 country code; a random match is chosen.
25    Country(String),
26    /// A uniformly random WireGuard-capable gateway.
27    Random,
28}
29
30/// Which WireGuard role a gateway must fulfil.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum WgRole {
34    /// Entry gateway (must declare the `entry` role).
35    Entry,
36    /// Exit gateway (must be able to operate as an exit gateway).
37    Exit,
38}
39
40/// A selected, registration-ready gateway.
41pub struct SelectedGateway {
42    /// The node plus a freshly generated client WireGuard keypair.
43    pub node: RegistrationNymNode,
44    /// The gateway's ed25519 identity.
45    pub identity: ed25519::PublicKey,
46    /// The gateway's advertised country (ISO 3166 alpha-2), if known.
47    pub country: Option<String>,
48    /// The gateway's directory node id.
49    pub node_id: u32,
50    /// The gateway's advertised IP address.
51    pub ip: std::net::IpAddr,
52    /// The gateway's human moniker from the dVPN directory, if configured/known.
53    pub name: Option<String>,
54    /// The gateway's QUIC bridge parameters, if it advertises one.
55    pub quic: Option<QuicBridge>,
56}
57
58impl SelectedGateway {
59    /// A copyable summary of this gateway's directory metadata.
60    pub fn info(&self) -> GatewayInfo {
61        GatewayInfo {
62            identity: self.identity,
63            node_id: self.node_id,
64            country: self.country.clone(),
65            ip: self.ip,
66            name: self.name.clone(),
67        }
68    }
69}
70
71/// Directory metadata for the gateway a tunnel hop terminates at.
72#[derive(Clone, Debug)]
73pub struct GatewayInfo {
74    /// ed25519 identity key.
75    pub identity: ed25519::PublicKey,
76    /// Directory node id.
77    pub node_id: u32,
78    /// Advertised country (ISO 3166 alpha-2), if known.
79    pub country: Option<String>,
80    /// Advertised IP address.
81    pub ip: std::net::IpAddr,
82    /// Human moniker from the dVPN directory, if configured/known.
83    pub name: Option<String>,
84}
85
86/// A node is usable for dVPN only if it advertises WireGuard, an authenticator, and LP data.
87///
88/// The node's declared mixnet entry/exit role is deliberately NOT checked: dVPN does not
89/// distinguish entry from exit nodes (that role only constrains mixnet mode), so any
90/// WireGuard-capable node can serve either dVPN hop. `role` is retained for API symmetry and future
91/// use but no longer filters the candidate set.
92fn wg_capable(desc: &NymNodeDescriptionV2, _role: WgRole) -> bool {
93    let d = &desc.description;
94    d.wireguard.is_some() && d.lewes_protocol.is_some() && d.authenticator.is_some()
95}
96
97/// Build the LP information block for a node, verifying its LP signature and
98/// deriving the ciphersuite from the node's version.
99fn build_lp(
100    desc: &NymNodeDescriptionV2,
101    identity: &ed25519::PublicKey,
102    ip: std::net::IpAddr,
103) -> Result<Option<NymNodeLPInformation>, SessionError> {
104    let Some(lp) = desc.description.lewes_protocol.as_ref() else {
105        return Ok(None);
106    };
107
108    let malformed = |reason: &str| SessionError::MalformedGateway {
109        identity: identity.to_base58_string(),
110        reason: reason.to_string(),
111    };
112
113    if !lp.verify(identity) {
114        return Err(malformed("invalid LP signature"));
115    }
116
117    let version: semver::Version = desc
118        .description
119        .build_information
120        .build_version
121        .parse()
122        .map_err(|_| malformed("unparseable build version"))?;
123    let ciphersuite = nym_kkt_ciphersuite::Ciphersuite::from_node_version(version)
124        .ok_or_else(|| malformed("no valid ciphersuite for node version"))?;
125    let expected_kem_key_hashes = lp
126        .content
127        .kem_keys()
128        .map_err(|_| malformed("malformed LP KEM key digests"))?;
129
130    Ok(Some(NymNodeLPInformation {
131        address: SocketAddr::new(ip, lp.content.control_port),
132        expected_kem_key_hashes,
133        x25519: lp.content.x25519,
134        ciphersuite,
135        // The directory carries no per-node LP protocol version; use ours.
136        lp_protocol_version: nym_lp_data::packet::version::CURRENT,
137    }))
138}
139
140/// Construct a `RegistrationNymNode` (node info + a fresh client WG keypair)
141/// from a described node.
142fn build_node(desc: &NymNodeDescriptionV2) -> Result<SelectedGateway, SessionError> {
143    let identity = desc.ed25519_identity_key();
144    let ip = desc
145        .description
146        .host_information
147        .ip_address
148        .first()
149        .copied()
150        .ok_or_else(|| SessionError::MalformedGateway {
151            identity: identity.to_base58_string(),
152            reason: "no advertised IP address".to_string(),
153        })?;
154
155    let lp_data = build_lp(desc, &identity, ip)?;
156    let authenticator_address = desc
157        .description
158        .authenticator
159        .as_ref()
160        .and_then(|a| a.address.parse().ok());
161    let version = nym_authenticator_requests::AuthenticatorVersion::from(
162        desc.description.build_information.build_version.as_str(),
163    );
164    let country = desc
165        .description
166        .auxiliary_details
167        .location
168        .as_ref()
169        .map(|c| c.alpha2.to_string());
170
171    let node = NymNodeInformation {
172        identity,
173        ip_address: ip,
174        ipr_address: None,
175        authenticator_address,
176        lp_data,
177        version,
178    };
179    // Fresh per-hop client WireGuard keypair.
180    let keys = Arc::new(x25519::KeyPair::new(&mut rand::thread_rng()));
181
182    Ok(SelectedGateway {
183        node: RegistrationNymNode { node, keys },
184        identity,
185        country,
186        node_id: desc.node_id,
187        ip,
188        name: None,
189        quic: None,
190    })
191}
192
193/// Build a gateway and enrich its moniker/QUIC bridge from the dVPN directory.
194fn build_and_enrich(
195    desc: &NymNodeDescriptionV2,
196    directory: Option<&DvpnDirectory>,
197) -> Result<SelectedGateway, SessionError> {
198    let mut selected = build_node(desc)?;
199    if let Some(entry) = directory.and_then(|d| d.entry(&selected.identity.to_base58_string())) {
200        selected.name = entry.name.clone();
201        selected.quic = entry.quic.clone();
202        // Prefer the directory's country when the described node lacks one.
203        if selected.country.is_none() {
204            selected.country = entry.country.clone();
205        }
206    }
207    Ok(selected)
208}
209
210/// Whether `identity` may be selected given the QUIC requirement.
211fn quic_ok(
212    directory: Option<&DvpnDirectory>,
213    require_quic: bool,
214    identity: &ed25519::PublicKey,
215) -> bool {
216    !require_quic || directory.is_some_and(|d| d.has_quic(&identity.to_base58_string()))
217}
218
219/// Select a gateway from the described-node set per the spec and role.
220///
221/// When `require_quic` is set, only gateways the dVPN `directory` reports as
222/// QUIC-bridge-capable are eligible; if none match, [`SessionError::NoQuicGateway`]
223/// is returned. `exclude` (the already-chosen hop's identity, e.g. the entry when
224/// picking the exit) is never selected, so a two-hop tunnel gets distinct gateways.
225pub(crate) fn select(
226    nodes: &[NymNodeDescriptionV2],
227    spec: &GatewaySpec,
228    role: WgRole,
229    directory: Option<&DvpnDirectory>,
230    require_quic: bool,
231    exclude: Option<&ed25519::PublicKey>,
232) -> Result<SelectedGateway, SessionError> {
233    let excluded = |id: &ed25519::PublicKey| exclude == Some(id);
234    match spec {
235        GatewaySpec::Identity(id) => {
236            if excluded(id) {
237                return Err(SessionError::SameGatewaySelected(id.to_base58_string()));
238            }
239            let desc = nodes
240                .iter()
241                .find(|n| &n.ed25519_identity_key() == id)
242                .ok_or_else(|| SessionError::GatewayNotFound(id.to_base58_string()))?;
243            if !wg_capable(desc, role) {
244                return Err(SessionError::NoWireguardGateway);
245            }
246            if !quic_ok(directory, require_quic, id) {
247                return Err(SessionError::NoQuicGateway {
248                    spec: id.to_base58_string(),
249                });
250            }
251            build_and_enrich(desc, directory)
252        }
253        GatewaySpec::Country(cc) => {
254            let candidates: Vec<&NymNodeDescriptionV2> = nodes
255                .iter()
256                .filter(|n| {
257                    let id = n.ed25519_identity_key();
258                    !excluded(&id)
259                        && wg_capable(n, role)
260                        && n.description
261                            .auxiliary_details
262                            .location
263                            .as_ref()
264                            .is_some_and(|c| c.alpha2.eq_ignore_ascii_case(cc))
265                        && quic_ok(directory, require_quic, &id)
266                })
267                .collect();
268            let desc = candidates.choose(&mut rand::thread_rng()).ok_or_else(|| {
269                if require_quic {
270                    SessionError::NoQuicGateway {
271                        spec: format!("country {cc}"),
272                    }
273                } else {
274                    SessionError::NoCountryMatch(cc.clone())
275                }
276            })?;
277            build_and_enrich(desc, directory)
278        }
279        GatewaySpec::Random => {
280            let candidates: Vec<&NymNodeDescriptionV2> = nodes
281                .iter()
282                .filter(|n| {
283                    let id = n.ed25519_identity_key();
284                    !excluded(&id) && wg_capable(n, role) && quic_ok(directory, require_quic, &id)
285                })
286                .collect();
287            let desc = candidates.choose(&mut rand::thread_rng()).ok_or_else(|| {
288                if require_quic {
289                    SessionError::NoQuicGateway {
290                        spec: "random".to_string(),
291                    }
292                } else {
293                    SessionError::NoWireguardGateway
294                }
295            })?;
296            build_and_enrich(desc, directory)
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    //! Selection error-path + role unit tests (OpenSpec task 3.8). Constructing
304    //! a fully-valid `NymNodeDescriptionV2` set is impractical, so these cover
305    //! the selection logic over an empty candidate set (the not-found / no-match
306    //! branches for every `GatewaySpec`), plus error surfacing — the paths a
307    //! caller depends on when a gateway is missing or unsupported.
308
309    use super::*;
310
311    fn random_identity() -> ed25519::PublicKey {
312        *ed25519::KeyPair::new(&mut rand::thread_rng()).public_key()
313    }
314
315    #[test]
316    fn identity_not_found_over_empty_set() {
317        let id = random_identity();
318        let err = select(
319            &[],
320            &GatewaySpec::Identity(id),
321            WgRole::Entry,
322            None,
323            false,
324            None,
325        )
326        .err()
327        .expect("expected selection error");
328        match err {
329            SessionError::GatewayNotFound(s) => assert_eq!(s, id.to_base58_string()),
330            other => panic!("expected GatewayNotFound, got {other:?}"),
331        }
332    }
333
334    #[test]
335    fn excluded_identity_is_rejected() {
336        // Selecting the excluded gateway (e.g. the entry, when picking the exit)
337        // fails up front so a two-hop tunnel gets distinct gateways.
338        let id =
339            ed25519::PublicKey::from_base58_string("Gejc2CnSRFUxK6519ewmWM66ytDZbbuXytwLUgytCQUD")
340                .unwrap();
341        let err = select(
342            &[],
343            &GatewaySpec::Identity(id),
344            WgRole::Exit,
345            None,
346            false,
347            Some(&id),
348        )
349        .err()
350        .expect("expected selection error");
351        match err {
352            SessionError::SameGatewaySelected(s) => assert_eq!(s, id.to_base58_string()),
353            other => panic!("expected SameGatewaySelected, got {other:?}"),
354        }
355    }
356
357    #[test]
358    fn country_no_match_over_empty_set() {
359        let err = select(
360            &[],
361            &GatewaySpec::Country("CH".into()),
362            WgRole::Exit,
363            None,
364            false,
365            None,
366        )
367        .err()
368        .expect("expected selection error");
369        match err {
370            SessionError::NoCountryMatch(cc) => assert_eq!(cc, "CH"),
371            other => panic!("expected NoCountryMatch, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn random_no_gateway_over_empty_set() {
377        let err = select(&[], &GatewaySpec::Random, WgRole::Entry, None, false, None)
378            .err()
379            .expect("expected selection error");
380        assert!(matches!(err, SessionError::NoWireguardGateway));
381    }
382
383    #[test]
384    fn require_quic_without_directory_fails() {
385        // With no directory (None), requiring QUIC can never be satisfied.
386        let err = select(&[], &GatewaySpec::Random, WgRole::Entry, None, true, None)
387            .err()
388            .expect("expected selection error");
389        assert!(matches!(err, SessionError::NoQuicGateway { .. }));
390    }
391
392    #[test]
393    fn role_is_copy_and_comparable() {
394        let r = WgRole::Entry;
395        let r2 = r; // Copy
396        assert_eq!(r, r2);
397        assert_ne!(WgRole::Entry, WgRole::Exit);
398    }
399
400    #[test]
401    fn error_messages_are_descriptive() {
402        assert!(SessionError::NoWireguardGateway
403            .to_string()
404            .contains("WireGuard-capable"));
405        assert!(SessionError::NoCountryMatch("DE".into())
406            .to_string()
407            .contains("DE"));
408        assert_eq!(
409            SessionError::Cancelled.to_string(),
410            "session setup was cancelled"
411        );
412    }
413}