nym_node_requests/api/
mod.rs

1// Copyright 2023-2025 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::api::v1::node::models::{
5    LegacyHostInformationV1, LegacyHostInformationV2, LegacyHostInformationV3,
6};
7use crate::error::Error;
8use nym_crypto::asymmetric::ed25519;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::fmt::{Display, Formatter};
12use std::ops::Deref;
13
14#[cfg(feature = "client")]
15pub mod client;
16pub mod v1;
17
18#[cfg(feature = "client")]
19pub use client::Client;
20
21// create the type alias manually if openapi is not enabled
22pub type SignedHostInformation = SignedData<crate::api::v1::node::models::HostInformation>;
23
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25pub struct SignedDataHostInfo {
26    // #[serde(flatten)]
27    pub data: crate::api::v1::node::models::HostInformation,
28    pub signature: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SignedData<T> {
33    // #[serde(flatten)]
34    pub data: T,
35    pub signature: String,
36}
37
38impl<T> SignedData<T> {
39    pub fn new(data: T, key: &ed25519::PrivateKey) -> Result<Self, Error>
40    where
41        T: Serialize,
42    {
43        let plaintext = serde_json::to_string(&data)?;
44
45        let signature = key.sign(plaintext).to_base58_string();
46        Ok(SignedData { data, signature })
47    }
48
49    pub fn verify(&self, key: &ed25519::PublicKey) -> bool
50    where
51        T: Serialize,
52    {
53        let Ok(plaintext) = serde_json::to_string(&self.data) else {
54            return false;
55        };
56
57        let Ok(signature) = ed25519::Signature::from_base58_string(&self.signature) else {
58            return false;
59        };
60
61        key.verify(plaintext, &signature).is_ok()
62    }
63}
64
65impl SignedHostInformation {
66    pub fn verify_host_information(&self) -> bool {
67        if self.verify(&self.keys.ed25519_identity) {
68            return true;
69        }
70
71        // TODO: @JS: to remove downgrade support in future release(s)
72
73        let legacy_v3 = SignedData {
74            data: LegacyHostInformationV3::from(self.data.clone()),
75            signature: self.signature.clone(),
76        };
77
78        if legacy_v3.verify(&self.keys.ed25519_identity) {
79            return true;
80        }
81
82        // attempt to verify legacy signatures
83        let legacy_v3 = SignedData {
84            data: LegacyHostInformationV3::from(self.data.clone()),
85            signature: self.signature.clone(),
86        };
87
88        if legacy_v3.verify(&self.keys.ed25519_identity) {
89            return true;
90        }
91
92        let legacy_v2 = SignedData {
93            data: LegacyHostInformationV2::from(legacy_v3.data),
94            signature: self.signature.clone(),
95        };
96
97        if legacy_v2.verify(&self.keys.ed25519_identity) {
98            return true;
99        }
100
101        SignedData {
102            data: LegacyHostInformationV1::from(legacy_v2.data),
103            signature: self.signature.clone(),
104        }
105        .verify(&self.keys.ed25519_identity)
106    }
107}
108
109impl<T> Deref for SignedData<T> {
110    type Target = T;
111
112    fn deref(&self) -> &Self::Target {
113        &self.data
114    }
115}
116
117#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
118pub struct ErrorResponse {
119    pub message: String,
120}
121
122impl Display for ErrorResponse {
123    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
124        self.message.fmt(f)
125    }
126}
127
128#[allow(deprecated)]
129#[cfg(test)]
130mod tests {
131
132    use super::*;
133    use crate::api::v1::node::models::{HostKeys, SphinxKey};
134    use nym_crypto::asymmetric::{ed25519, x25519};
135    use nym_noise_keys::{NoiseVersion, VersionedNoiseKey};
136    use rand_chacha::rand_core::SeedableRng;
137
138    #[test]
139    fn dummy_signed_host_verification() {
140        let mut rng = rand_chacha::ChaCha20Rng::from_seed([0u8; 32]);
141        let ed22519 = ed25519::KeyPair::new(&mut rng);
142        let x25519_sphinx = x25519::KeyPair::new(&mut rng);
143        let x25519_sphinx2 = x25519::KeyPair::new(&mut rng);
144        let x25519_versioned_noise = VersionedNoiseKey {
145            supported_version: NoiseVersion::V1,
146            x25519_pubkey: *x25519::KeyPair::new(&mut rng).public_key(),
147        };
148
149        let current_rotation_id = 1234;
150
151        // no pre-announced keys
152        let host_info = crate::api::v1::node::models::HostInformation {
153            ip_address: vec!["1.1.1.1".parse().unwrap()],
154            hostname: Some("foomp.com".to_string()),
155            keys: crate::api::v1::node::models::HostKeys {
156                ed25519_identity: *ed22519.public_key(),
157                x25519_sphinx: *x25519_sphinx.public_key(),
158                primary_x25519_sphinx_key: SphinxKey {
159                    rotation_id: current_rotation_id,
160                    public_key: *x25519_sphinx.public_key(),
161                },
162                pre_announced_x25519_sphinx_key: None,
163                x25519_versioned_noise: None,
164            },
165        };
166
167        let signed_info = SignedHostInformation::new(host_info, ed22519.private_key()).unwrap();
168        assert!(signed_info.verify(ed22519.public_key()));
169        assert!(signed_info.verify_host_information());
170
171        let host_info_with_noise = crate::api::v1::node::models::HostInformation {
172            ip_address: vec!["1.1.1.1".parse().unwrap()],
173            hostname: Some("foomp.com".to_string()),
174            keys: crate::api::v1::node::models::HostKeys {
175                ed25519_identity: *ed22519.public_key(),
176                x25519_sphinx: *x25519_sphinx.public_key(),
177                primary_x25519_sphinx_key: SphinxKey {
178                    rotation_id: current_rotation_id,
179                    public_key: *x25519_sphinx.public_key(),
180                },
181                pre_announced_x25519_sphinx_key: None,
182                x25519_versioned_noise: Some(x25519_versioned_noise),
183            },
184        };
185
186        let signed_info =
187            SignedHostInformation::new(host_info_with_noise, ed22519.private_key()).unwrap();
188        assert!(signed_info.verify(ed22519.public_key()));
189        assert!(signed_info.verify_host_information());
190
191        // with pre-announced keys
192        let host_info = crate::api::v1::node::models::HostInformation {
193            ip_address: vec!["1.1.1.1".parse().unwrap()],
194            hostname: Some("foomp.com".to_string()),
195            keys: crate::api::v1::node::models::HostKeys {
196                ed25519_identity: *ed22519.public_key(),
197                x25519_sphinx: *x25519_sphinx.public_key(),
198                primary_x25519_sphinx_key: SphinxKey {
199                    rotation_id: current_rotation_id,
200                    public_key: *x25519_sphinx.public_key(),
201                },
202                pre_announced_x25519_sphinx_key: Some(SphinxKey {
203                    rotation_id: current_rotation_id + 1,
204                    public_key: *x25519_sphinx2.public_key(),
205                }),
206                x25519_versioned_noise: None,
207            },
208        };
209
210        let signed_info = SignedHostInformation::new(host_info, ed22519.private_key()).unwrap();
211        assert!(signed_info.verify(ed22519.public_key()));
212        assert!(signed_info.verify_host_information());
213
214        let host_info_with_noise = crate::api::v1::node::models::HostInformation {
215            ip_address: vec!["1.1.1.1".parse().unwrap()],
216            hostname: Some("foomp.com".to_string()),
217            keys: crate::api::v1::node::models::HostKeys {
218                ed25519_identity: *ed22519.public_key(),
219                x25519_sphinx: *x25519_sphinx.public_key(),
220                primary_x25519_sphinx_key: SphinxKey {
221                    rotation_id: current_rotation_id,
222                    public_key: *x25519_sphinx.public_key(),
223                },
224                pre_announced_x25519_sphinx_key: Some(SphinxKey {
225                    rotation_id: current_rotation_id + 1,
226                    public_key: *x25519_sphinx2.public_key(),
227                }),
228                x25519_versioned_noise: Some(x25519_versioned_noise),
229            },
230        };
231
232        let signed_info =
233            SignedHostInformation::new(host_info_with_noise, ed22519.private_key()).unwrap();
234        assert!(signed_info.verify(ed22519.public_key()));
235        assert!(signed_info.verify_host_information());
236    }
237
238    #[test]
239    fn dummy_legacy_v3_signed_host_verification() {
240        let mut rng = rand_chacha::ChaCha20Rng::from_seed([0u8; 32]);
241        let ed22519 = ed25519::KeyPair::new(&mut rng);
242        let x25519_sphinx = x25519::KeyPair::new(&mut rng);
243        let x25519_noise = x25519::KeyPair::new(&mut rng);
244
245        let legacy_info_no_noise = crate::api::v1::node::models::LegacyHostInformationV3 {
246            ip_address: vec!["1.1.1.1".parse().unwrap()],
247            hostname: Some("foomp.com".to_string()),
248            keys: crate::api::v1::node::models::LegacyHostKeysV3 {
249                ed25519_identity: *ed22519.public_key(),
250                x25519_sphinx: *x25519_sphinx.public_key(),
251                x25519_noise: None,
252            },
253        };
254
255        // note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into)
256        let current_struct = crate::api::v1::node::models::HostInformation {
257            ip_address: vec!["1.1.1.1".parse().unwrap()],
258            hostname: Some("foomp.com".to_string()),
259            keys: HostKeys {
260                ed25519_identity: *ed22519.public_key(),
261                x25519_sphinx: *x25519_sphinx.public_key(),
262                primary_x25519_sphinx_key: SphinxKey {
263                    rotation_id: u32::MAX,
264                    public_key: *x25519_sphinx.public_key(),
265                },
266                pre_announced_x25519_sphinx_key: None,
267                x25519_versioned_noise: None,
268            },
269        };
270
271        // signature on legacy data
272        let signature = SignedData::new(legacy_info_no_noise, ed22519.private_key())
273            .unwrap()
274            .signature;
275
276        // signed blob with the 'current' structure
277        let current_struct = SignedData {
278            data: current_struct,
279            signature,
280        };
281
282        assert!(!current_struct.verify(ed22519.public_key()));
283        assert!(current_struct.verify_host_information());
284
285        // //technically this variant should never happen
286        let legacy_info_noise = crate::api::v1::node::models::LegacyHostInformationV3 {
287            ip_address: vec!["1.1.1.1".parse().unwrap()],
288            hostname: Some("foomp.com".to_string()),
289            keys: crate::api::v1::node::models::LegacyHostKeysV3 {
290                ed25519_identity: *ed22519.public_key(),
291                x25519_sphinx: *x25519_sphinx.public_key(),
292                x25519_noise: Some(*x25519_noise.public_key()),
293            },
294        };
295
296        // note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into)
297        let current_struct_noise = crate::api::v1::node::models::HostInformation {
298            ip_address: vec!["1.1.1.1".parse().unwrap()],
299            hostname: Some("foomp.com".to_string()),
300            keys: HostKeys {
301                ed25519_identity: *ed22519.public_key(),
302                x25519_sphinx: *x25519_sphinx.public_key(),
303                primary_x25519_sphinx_key: SphinxKey {
304                    rotation_id: u32::MAX,
305                    public_key: *x25519_sphinx.public_key(),
306                },
307                pre_announced_x25519_sphinx_key: None,
308                x25519_versioned_noise: Some(VersionedNoiseKey {
309                    supported_version: NoiseVersion::V1,
310                    x25519_pubkey: legacy_info_noise.keys.x25519_noise.unwrap(),
311                }),
312            },
313        };
314
315        // signature on legacy data
316
317        let signature_noise = SignedData::new(legacy_info_noise, ed22519.private_key())
318            .unwrap()
319            .signature;
320
321        // signed blob with the 'current' structure
322
323        let current_struct_noise = SignedData {
324            data: current_struct_noise,
325            signature: signature_noise,
326        };
327
328        assert!(!current_struct_noise.verify(ed22519.public_key()));
329        assert!(current_struct_noise.verify_host_information())
330    }
331
332    #[test]
333    fn dummy_legacy_v2_signed_host_verification() {
334        let mut rng = rand_chacha::ChaCha20Rng::from_seed([0u8; 32]);
335        let ed22519 = ed25519::KeyPair::new(&mut rng);
336        let x25519_sphinx = x25519::KeyPair::new(&mut rng);
337        let x25519_noise = x25519::KeyPair::new(&mut rng);
338
339        let legacy_info_no_noise = crate::api::v1::node::models::LegacyHostInformationV2 {
340            ip_address: vec!["1.1.1.1".parse().unwrap()],
341            hostname: Some("foomp.com".to_string()),
342            keys: crate::api::v1::node::models::LegacyHostKeysV2 {
343                ed25519_identity: ed22519.public_key().to_base58_string(),
344                x25519_sphinx: x25519_sphinx.public_key().to_base58_string(),
345                x25519_noise: "".to_string(),
346            },
347        };
348
349        let legacy_info_noise = crate::api::v1::node::models::LegacyHostInformationV2 {
350            ip_address: vec!["1.1.1.1".parse().unwrap()],
351            hostname: Some("foomp.com".to_string()),
352            keys: crate::api::v1::node::models::LegacyHostKeysV2 {
353                ed25519_identity: ed22519.public_key().to_base58_string(),
354                x25519_sphinx: x25519_sphinx.public_key().to_base58_string(),
355                x25519_noise: x25519_noise.public_key().to_base58_string(),
356            },
357        };
358
359        // note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into)
360        let host_info_no_noise = crate::api::v1::node::models::HostInformation {
361            ip_address: legacy_info_no_noise.ip_address.clone(),
362            hostname: legacy_info_no_noise.hostname.clone(),
363            keys: crate::api::v1::node::models::HostKeys {
364                ed25519_identity: legacy_info_no_noise.keys.ed25519_identity.parse().unwrap(),
365                x25519_sphinx: *x25519_sphinx.public_key(),
366                primary_x25519_sphinx_key: SphinxKey {
367                    rotation_id: u32::MAX,
368                    public_key: *x25519_sphinx.public_key(),
369                },
370                pre_announced_x25519_sphinx_key: None,
371                x25519_versioned_noise: None,
372            },
373        };
374
375        // note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into)
376        let host_info_noise = crate::api::v1::node::models::HostInformation {
377            ip_address: legacy_info_noise.ip_address.clone(),
378            hostname: legacy_info_noise.hostname.clone(),
379            keys: crate::api::v1::node::models::HostKeys {
380                ed25519_identity: legacy_info_noise.keys.ed25519_identity.parse().unwrap(),
381                x25519_sphinx: *x25519_sphinx.public_key(),
382                primary_x25519_sphinx_key: SphinxKey {
383                    rotation_id: u32::MAX,
384                    public_key: *x25519_sphinx.public_key(),
385                },
386                pre_announced_x25519_sphinx_key: None,
387                x25519_versioned_noise: Some(VersionedNoiseKey {
388                    supported_version: NoiseVersion::V1,
389                    x25519_pubkey: legacy_info_noise.keys.x25519_noise.parse().unwrap(),
390                }),
391            },
392        };
393
394        // signature on legacy data
395        let signature_no_noise = SignedData::new(legacy_info_no_noise, ed22519.private_key())
396            .unwrap()
397            .signature;
398
399        let signature_noise = SignedData::new(legacy_info_noise, ed22519.private_key())
400            .unwrap()
401            .signature;
402
403        // signed blob with the 'current' structure
404        let current_struct_no_noise = SignedData {
405            data: host_info_no_noise,
406            signature: signature_no_noise,
407        };
408
409        let current_struct_noise = SignedData {
410            data: host_info_noise,
411            signature: signature_noise,
412        };
413
414        assert!(!current_struct_no_noise.verify(ed22519.public_key()));
415        assert!(current_struct_no_noise.verify_host_information());
416
417        assert!(!current_struct_noise.verify(ed22519.public_key()));
418        assert!(current_struct_noise.verify_host_information())
419    }
420
421    #[test]
422    fn dummy_legacy_v1_signed_host_verification() {
423        let mut rng = rand_chacha::ChaCha20Rng::from_seed([0u8; 32]);
424        let ed22519 = ed25519::KeyPair::new(&mut rng);
425        let x25519_sphinx = x25519::KeyPair::new(&mut rng);
426
427        let legacy_info = crate::api::v1::node::models::LegacyHostInformationV1 {
428            ip_address: vec!["1.1.1.1".parse().unwrap()],
429            hostname: Some("foomp.com".to_string()),
430            keys: crate::api::v1::node::models::LegacyHostKeysV1 {
431                ed25519: ed22519.public_key().to_base58_string(),
432                x25519: x25519_sphinx.public_key().to_base58_string(),
433            },
434        };
435
436        // note the usage of u32::max rotation id (as that's what the legacy data would be deserialised into)
437        let host_info = crate::api::v1::node::models::HostInformation {
438            ip_address: legacy_info.ip_address.clone(),
439            hostname: legacy_info.hostname.clone(),
440            keys: crate::api::v1::node::models::HostKeys {
441                ed25519_identity: legacy_info.keys.ed25519.parse().unwrap(),
442                x25519_sphinx: *x25519_sphinx.public_key(),
443                primary_x25519_sphinx_key: SphinxKey {
444                    rotation_id: u32::MAX,
445                    public_key: *x25519_sphinx.public_key(),
446                },
447                pre_announced_x25519_sphinx_key: None,
448                x25519_versioned_noise: None,
449            },
450        };
451
452        // signature on legacy data
453        let signature = SignedData::new(legacy_info, ed22519.private_key())
454            .unwrap()
455            .signature;
456
457        // signed blob with the 'current' structure
458        let current_struct = SignedData {
459            data: host_info,
460            signature,
461        };
462
463        assert!(!current_struct.verify(ed22519.public_key()));
464        assert!(current_struct.verify_host_information())
465    }
466}