async_snmp/client/
auth.rs

1//! Authentication configuration types for the SNMP client.
2//!
3//! This module provides the [`Auth`] enum for specifying authentication
4//! configuration, supporting SNMPv1/v2c community strings and SNMPv3 USM.
5//!
6//! # Master Key Caching
7//!
8//! For high-throughput polling of many engines with shared credentials, use
9//! [`MasterKeys`](crate::MasterKeys) to cache the expensive password-to-key
10//! derivation:
11//!
12//! ```rust
13//! use async_snmp::{Auth, AuthProtocol, PrivProtocol, MasterKeys};
14//!
15//! // Derive master keys once (expensive: ~850μs for SHA-256)
16//! let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpassword")
17//!     .with_privacy(PrivProtocol::Aes128, b"privpassword");
18//!
19//! // Use with USM builder - localization is cheap (~1μs per engine)
20//! let auth: Auth = Auth::usm("admin")
21//!     .with_master_keys(master_keys)
22//!     .into();
23//! ```
24
25use crate::v3::{AuthProtocol, MasterKeys, PrivProtocol};
26
27/// SNMP version for community-based authentication.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub enum CommunityVersion {
31    /// SNMPv1
32    V1,
33    /// SNMPv2c
34    #[default]
35    V2c,
36}
37
38/// Authentication configuration for SNMP clients.
39#[derive(Debug, Clone)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[cfg_attr(feature = "serde", serde(untagged))]
42pub enum Auth {
43    /// Community string authentication (SNMPv1 or v2c).
44    Community {
45        /// SNMP version (V1 or V2c)
46        #[cfg_attr(feature = "serde", serde(default))]
47        version: CommunityVersion,
48        /// Community string
49        community: String,
50    },
51    /// User-based Security Model (SNMPv3).
52    Usm(UsmAuth),
53}
54
55impl Default for Auth {
56    fn default() -> Self {
57        Auth::v2c("public")
58    }
59}
60
61impl Auth {
62    /// SNMPv1 community authentication.
63    pub fn v1(community: impl Into<String>) -> Self {
64        Auth::Community {
65            version: CommunityVersion::V1,
66            community: community.into(),
67        }
68    }
69
70    /// SNMPv2c community authentication.
71    pub fn v2c(community: impl Into<String>) -> Self {
72        Auth::Community {
73            version: CommunityVersion::V2c,
74            community: community.into(),
75        }
76    }
77
78    /// Start building SNMPv3 USM authentication.
79    pub fn usm(username: impl Into<String>) -> UsmBuilder {
80        UsmBuilder::new(username)
81    }
82}
83
84/// SNMPv3 USM authentication parameters.
85#[derive(Debug, Clone)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87pub struct UsmAuth {
88    /// SNMPv3 username
89    pub username: String,
90    /// Authentication protocol (None for noAuthNoPriv)
91    #[cfg_attr(
92        feature = "serde",
93        serde(default, skip_serializing_if = "Option::is_none")
94    )]
95    pub auth_protocol: Option<AuthProtocol>,
96    /// Authentication password
97    #[cfg_attr(
98        feature = "serde",
99        serde(default, skip_serializing_if = "Option::is_none")
100    )]
101    pub auth_password: Option<String>,
102    /// Privacy protocol (None for noPriv)
103    #[cfg_attr(
104        feature = "serde",
105        serde(default, skip_serializing_if = "Option::is_none")
106    )]
107    pub priv_protocol: Option<PrivProtocol>,
108    /// Privacy password
109    #[cfg_attr(
110        feature = "serde",
111        serde(default, skip_serializing_if = "Option::is_none")
112    )]
113    pub priv_password: Option<String>,
114    /// SNMPv3 context name for VACM context selection.
115    /// Most deployments use empty string (default).
116    #[cfg_attr(
117        feature = "serde",
118        serde(default, skip_serializing_if = "Option::is_none")
119    )]
120    pub context_name: Option<String>,
121    /// Pre-computed master keys for caching.
122    /// When set, passwords are ignored and keys are derived from master keys.
123    #[cfg_attr(feature = "serde", serde(skip))]
124    pub master_keys: Option<MasterKeys>,
125}
126
127/// Builder for SNMPv3 USM authentication.
128pub struct UsmBuilder {
129    username: String,
130    auth: Option<(AuthProtocol, String)>,
131    privacy: Option<(PrivProtocol, String)>,
132    context_name: Option<String>,
133    master_keys: Option<MasterKeys>,
134}
135
136impl UsmBuilder {
137    /// Create a new USM builder with the given username.
138    pub fn new(username: impl Into<String>) -> Self {
139        Self {
140            username: username.into(),
141            auth: None,
142            privacy: None,
143            context_name: None,
144            master_keys: None,
145        }
146    }
147
148    /// Add authentication (authNoPriv or authPriv).
149    ///
150    /// This method performs the full key derivation (~850μs for SHA-256) when
151    /// the client connects. For high-throughput polling of many engines,
152    /// consider using [`with_master_keys`](Self::with_master_keys) instead.
153    pub fn auth(mut self, protocol: AuthProtocol, password: impl Into<String>) -> Self {
154        self.auth = Some((protocol, password.into()));
155        self
156    }
157
158    /// Add privacy/encryption (authPriv).
159    ///
160    /// Requires auth; validated at connection time.
161    pub fn privacy(mut self, protocol: PrivProtocol, password: impl Into<String>) -> Self {
162        self.privacy = Some((protocol, password.into()));
163        self
164    }
165
166    /// Use pre-computed master keys for authentication and privacy.
167    ///
168    /// This is the efficient path for high-throughput polling of many engines
169    /// with shared credentials. The expensive password-to-key derivation
170    /// (~850μs) is done once when creating the [`MasterKeys`], and only the
171    /// cheap localization (~1μs) is performed per engine.
172    ///
173    /// When master keys are set, the [`auth`](Self::auth) and
174    /// [`privacy`](Self::privacy) methods are ignored.
175    ///
176    /// # Example
177    ///
178    /// ```rust
179    /// use async_snmp::{Auth, AuthProtocol, PrivProtocol, MasterKeys};
180    ///
181    /// // Derive master keys once
182    /// let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpassword")
183    ///     .with_privacy(PrivProtocol::Aes128, b"privpassword");
184    ///
185    /// // Use with multiple clients
186    /// let auth: Auth = Auth::usm("admin")
187    ///     .with_master_keys(master_keys)
188    ///     .into();
189    /// ```
190    pub fn with_master_keys(mut self, master_keys: MasterKeys) -> Self {
191        self.master_keys = Some(master_keys);
192        self
193    }
194
195    /// Set the SNMPv3 context name for VACM context selection.
196    /// Most deployments use empty string (default).
197    pub fn context_name(mut self, name: impl Into<String>) -> Self {
198        self.context_name = Some(name.into());
199        self
200    }
201}
202
203impl From<UsmBuilder> for Auth {
204    fn from(b: UsmBuilder) -> Auth {
205        Auth::Usm(UsmAuth {
206            username: b.username,
207            auth_protocol: b
208                .master_keys
209                .as_ref()
210                .map(|m| m.auth_protocol())
211                .or(b.auth.as_ref().map(|(p, _)| *p)),
212            auth_password: b.auth.map(|(_, pw)| pw),
213            priv_protocol: b
214                .master_keys
215                .as_ref()
216                .and_then(|m| m.priv_protocol())
217                .or(b.privacy.as_ref().map(|(p, _)| *p)),
218            priv_password: b.privacy.map(|(_, pw)| pw),
219            context_name: b.context_name,
220            master_keys: b.master_keys,
221        })
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_default_auth() {
231        let auth = Auth::default();
232        match auth {
233            Auth::Community { version, community } => {
234                assert_eq!(version, CommunityVersion::V2c);
235                assert_eq!(community, "public");
236            }
237            _ => panic!("expected Community variant"),
238        }
239    }
240
241    #[test]
242    fn test_v1_auth() {
243        let auth = Auth::v1("private");
244        match auth {
245            Auth::Community { version, community } => {
246                assert_eq!(version, CommunityVersion::V1);
247                assert_eq!(community, "private");
248            }
249            _ => panic!("expected Community variant"),
250        }
251    }
252
253    #[test]
254    fn test_v2c_auth() {
255        let auth = Auth::v2c("secret");
256        match auth {
257            Auth::Community { version, community } => {
258                assert_eq!(version, CommunityVersion::V2c);
259                assert_eq!(community, "secret");
260            }
261            _ => panic!("expected Community variant"),
262        }
263    }
264
265    #[test]
266    fn test_community_version_default() {
267        let version = CommunityVersion::default();
268        assert_eq!(version, CommunityVersion::V2c);
269    }
270
271    #[test]
272    fn test_usm_no_auth_no_priv() {
273        let auth: Auth = Auth::usm("readonly").into();
274        match auth {
275            Auth::Usm(usm) => {
276                assert_eq!(usm.username, "readonly");
277                assert!(usm.auth_protocol.is_none());
278                assert!(usm.auth_password.is_none());
279                assert!(usm.priv_protocol.is_none());
280                assert!(usm.priv_password.is_none());
281                assert!(usm.context_name.is_none());
282            }
283            _ => panic!("expected Usm variant"),
284        }
285    }
286
287    #[test]
288    fn test_usm_auth_no_priv() {
289        let auth: Auth = Auth::usm("admin")
290            .auth(AuthProtocol::Sha256, "authpass123")
291            .into();
292        match auth {
293            Auth::Usm(usm) => {
294                assert_eq!(usm.username, "admin");
295                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha256));
296                assert_eq!(usm.auth_password, Some("authpass123".to_string()));
297                assert!(usm.priv_protocol.is_none());
298                assert!(usm.priv_password.is_none());
299            }
300            _ => panic!("expected Usm variant"),
301        }
302    }
303
304    #[test]
305    fn test_usm_auth_priv() {
306        let auth: Auth = Auth::usm("admin")
307            .auth(AuthProtocol::Sha256, "authpass")
308            .privacy(PrivProtocol::Aes128, "privpass")
309            .into();
310        match auth {
311            Auth::Usm(usm) => {
312                assert_eq!(usm.username, "admin");
313                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha256));
314                assert_eq!(usm.auth_password, Some("authpass".to_string()));
315                assert_eq!(usm.priv_protocol, Some(PrivProtocol::Aes128));
316                assert_eq!(usm.priv_password, Some("privpass".to_string()));
317            }
318            _ => panic!("expected Usm variant"),
319        }
320    }
321
322    #[test]
323    fn test_usm_with_context_name() {
324        let auth: Auth = Auth::usm("admin")
325            .auth(AuthProtocol::Sha256, "authpass")
326            .context_name("vlan100")
327            .into();
328        match auth {
329            Auth::Usm(usm) => {
330                assert_eq!(usm.username, "admin");
331                assert_eq!(usm.context_name, Some("vlan100".to_string()));
332            }
333            _ => panic!("expected Usm variant"),
334        }
335    }
336
337    #[test]
338    fn test_usm_builder_chaining() {
339        // Verify all methods can be chained
340        let auth: Auth = Auth::usm("user")
341            .auth(AuthProtocol::Sha512, "auth")
342            .privacy(PrivProtocol::Aes256, "priv")
343            .context_name("ctx")
344            .into();
345
346        match auth {
347            Auth::Usm(usm) => {
348                assert_eq!(usm.username, "user");
349                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha512));
350                assert_eq!(usm.auth_password, Some("auth".to_string()));
351                assert_eq!(usm.priv_protocol, Some(PrivProtocol::Aes256));
352                assert_eq!(usm.priv_password, Some("priv".to_string()));
353                assert_eq!(usm.context_name, Some("ctx".to_string()));
354            }
355            _ => panic!("expected Usm variant"),
356        }
357    }
358}
359
360#[cfg(all(test, feature = "serde"))]
361mod serde_tests {
362    use super::*;
363
364    #[test]
365    fn test_community_v2c_roundtrip() {
366        let auth = Auth::v2c("public");
367        let json = serde_json::to_string(&auth).unwrap();
368        let back: Auth = serde_json::from_str(&json).unwrap();
369
370        match back {
371            Auth::Community { version, community } => {
372                assert_eq!(version, CommunityVersion::V2c);
373                assert_eq!(community, "public");
374            }
375            _ => panic!("expected Community variant"),
376        }
377    }
378
379    #[test]
380    fn test_community_v1_roundtrip() {
381        let auth = Auth::v1("private");
382        let json = serde_json::to_string(&auth).unwrap();
383        let back: Auth = serde_json::from_str(&json).unwrap();
384
385        match back {
386            Auth::Community { version, community } => {
387                assert_eq!(version, CommunityVersion::V1);
388                assert_eq!(community, "private");
389            }
390            _ => panic!("expected Community variant"),
391        }
392    }
393
394    #[test]
395    fn test_usm_no_auth_roundtrip() {
396        let auth: Auth = Auth::usm("readonly").into();
397        let json = serde_json::to_string(&auth).unwrap();
398        let back: Auth = serde_json::from_str(&json).unwrap();
399
400        match back {
401            Auth::Usm(usm) => {
402                assert_eq!(usm.username, "readonly");
403                assert!(usm.auth_protocol.is_none());
404                assert!(usm.auth_password.is_none());
405                assert!(usm.priv_protocol.is_none());
406                assert!(usm.priv_password.is_none());
407            }
408            _ => panic!("expected Usm variant"),
409        }
410    }
411
412    #[test]
413    fn test_usm_auth_priv_roundtrip() {
414        let auth: Auth = Auth::usm("admin")
415            .auth(AuthProtocol::Sha256, "authpass")
416            .privacy(PrivProtocol::Aes128, "privpass")
417            .context_name("vlan100")
418            .into();
419
420        let json = serde_json::to_string(&auth).unwrap();
421        let back: Auth = serde_json::from_str(&json).unwrap();
422
423        match back {
424            Auth::Usm(usm) => {
425                assert_eq!(usm.username, "admin");
426                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha256));
427                assert_eq!(usm.auth_password, Some("authpass".to_string()));
428                assert_eq!(usm.priv_protocol, Some(PrivProtocol::Aes128));
429                assert_eq!(usm.priv_password, Some("privpass".to_string()));
430                assert_eq!(usm.context_name, Some("vlan100".to_string()));
431            }
432            _ => panic!("expected Usm variant"),
433        }
434    }
435
436    #[test]
437    fn test_community_deserialize_without_version() {
438        // When deserializing, version should default to V2c if not present
439        let json = r#"{"community":"public"}"#;
440        let auth: Auth = serde_json::from_str(json).unwrap();
441
442        match auth {
443            Auth::Community { version, community } => {
444                assert_eq!(version, CommunityVersion::V2c);
445                assert_eq!(community, "public");
446            }
447            _ => panic!("expected Community variant"),
448        }
449    }
450
451    #[test]
452    fn test_usm_optional_fields_not_serialized_when_none() {
453        let auth: Auth = Auth::usm("readonly").into();
454        let json = serde_json::to_string(&auth).unwrap();
455
456        // Should only contain username, no None fields
457        assert!(json.contains("username"));
458        assert!(!json.contains("auth_protocol"));
459        assert!(!json.contains("auth_password"));
460        assert!(!json.contains("priv_protocol"));
461        assert!(!json.contains("priv_password"));
462        assert!(!json.contains("context_name"));
463    }
464
465    #[test]
466    fn test_walk_mode_roundtrip() {
467        use crate::client::walk::WalkMode;
468
469        let modes = [WalkMode::Auto, WalkMode::GetNext, WalkMode::GetBulk];
470
471        for mode in modes {
472            let json = serde_json::to_string(&mode).unwrap();
473            let back: WalkMode = serde_json::from_str(&json).unwrap();
474            assert_eq!(back, mode);
475        }
476    }
477
478    #[test]
479    fn test_oid_ordering_roundtrip() {
480        use crate::client::walk::OidOrdering;
481
482        let orderings = [OidOrdering::Strict, OidOrdering::AllowNonIncreasing];
483
484        for ordering in orderings {
485            let json = serde_json::to_string(&ordering).unwrap();
486            let back: OidOrdering = serde_json::from_str(&json).unwrap();
487            assert_eq!(back, ordering);
488        }
489    }
490
491    #[test]
492    fn test_auth_protocol_roundtrip() {
493        let protocols = [
494            AuthProtocol::Md5,
495            AuthProtocol::Sha1,
496            AuthProtocol::Sha224,
497            AuthProtocol::Sha256,
498            AuthProtocol::Sha384,
499            AuthProtocol::Sha512,
500        ];
501
502        for proto in protocols {
503            let json = serde_json::to_string(&proto).unwrap();
504            let back: AuthProtocol = serde_json::from_str(&json).unwrap();
505            assert_eq!(back, proto);
506        }
507    }
508
509    #[test]
510    fn test_priv_protocol_roundtrip() {
511        let protocols = [
512            PrivProtocol::Des,
513            PrivProtocol::Aes128,
514            PrivProtocol::Aes192,
515            PrivProtocol::Aes256,
516        ];
517
518        for proto in protocols {
519            let json = serde_json::to_string(&proto).unwrap();
520            let back: PrivProtocol = serde_json::from_str(&json).unwrap();
521            assert_eq!(back, proto);
522        }
523    }
524}