Skip to main content

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//! When polling many engines with shared credentials, use
9//! [`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").unwrap()
17//!     .with_privacy(PrivProtocol::Aes128, b"privpassword").unwrap();
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, PartialOrd, Ord, Hash)]
29pub enum CommunityVersion {
30    /// `SNMPv1`
31    V1,
32    /// `SNMPv2c`
33    #[default]
34    V2c,
35}
36
37/// Authentication configuration for SNMP clients.
38///
39/// The [`Debug`] implementation redacts community strings so that credentials
40/// are not leaked through logs or diagnostics.
41#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub enum Auth {
43    /// Community string authentication (`SNMPv1` or v2c).
44    Community {
45        /// SNMP version (V1 or V2c)
46        version: CommunityVersion,
47        /// Community string
48        community: String,
49    },
50    /// User-based Security Model (`SNMPv3`).
51    Usm(UsmAuth),
52}
53
54impl Default for Auth {
55    /// Returns `Auth::v2c("public")`.
56    fn default() -> Self {
57        Auth::v2c("public")
58    }
59}
60
61impl Auth {
62    /// `SNMPv1` community authentication.
63    ///
64    /// Creates authentication configuration for `SNMPv1`, which only supports
65    /// community string authentication without encryption.
66    ///
67    /// # Example
68    ///
69    /// ```rust
70    /// use async_snmp::Auth;
71    ///
72    /// // Create SNMPv1 authentication with "private" community
73    /// let auth = Auth::v1("private");
74    /// ```
75    pub fn v1(community: impl Into<String>) -> Self {
76        Auth::Community {
77            version: CommunityVersion::V1,
78            community: community.into(),
79        }
80    }
81
82    /// `SNMPv2c` community authentication.
83    ///
84    /// Creates authentication configuration for `SNMPv2c`, which supports
85    /// community string authentication without encryption but adds GETBULK
86    /// and improved error handling over `SNMPv1`.
87    ///
88    /// # Example
89    ///
90    /// ```rust
91    /// use async_snmp::Auth;
92    ///
93    /// // Create SNMPv2c authentication with "public" community
94    /// let auth = Auth::v2c("public");
95    ///
96    /// // Auth::default() is equivalent to Auth::v2c("public")
97    /// let auth = Auth::default();
98    /// ```
99    pub fn v2c(community: impl Into<String>) -> Self {
100        Auth::Community {
101            version: CommunityVersion::V2c,
102            community: community.into(),
103        }
104    }
105
106    /// Start building `SNMPv3` USM authentication.
107    ///
108    /// Returns a builder that allows configuring authentication and privacy
109    /// protocols. `SNMPv3` supports three security levels:
110    /// - noAuthNoPriv: username only (no security)
111    /// - authNoPriv: username with authentication (integrity)
112    /// - authPriv: username with authentication and encryption (confidentiality)
113    ///
114    /// # Example
115    ///
116    /// ```rust
117    /// use async_snmp::{Auth, AuthProtocol, PrivProtocol};
118    ///
119    /// // noAuthNoPriv: username only
120    /// let auth: Auth = Auth::usm("readonly").into();
121    ///
122    /// // authNoPriv: with authentication
123    /// let auth: Auth = Auth::usm("admin")
124    ///     .auth(AuthProtocol::Sha256, "authpassword")
125    ///     .into();
126    ///
127    /// // authPriv: with authentication and encryption
128    /// let auth: Auth = Auth::usm("admin")
129    ///     .auth(AuthProtocol::Sha256, "authpassword")
130    ///     .privacy(PrivProtocol::Aes128, "privpassword")
131    ///     .into();
132    /// ```
133    pub fn usm(username: impl Into<String>) -> UsmBuilder {
134        UsmBuilder::new(username)
135    }
136}
137
138/// `SNMPv3` USM authentication parameters.
139///
140/// The [`Debug`] implementation redacts the authentication and privacy
141/// passwords so that credentials are not leaked through logs or diagnostics.
142#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
143pub struct UsmAuth {
144    /// `SNMPv3` username
145    pub username: String,
146    /// Authentication protocol (None for noAuthNoPriv)
147    pub auth_protocol: Option<AuthProtocol>,
148    /// Authentication password
149    pub auth_password: Option<String>,
150    /// Privacy protocol (None for noPriv)
151    pub priv_protocol: Option<PrivProtocol>,
152    /// Privacy password
153    pub priv_password: Option<String>,
154    /// `SNMPv3` context name for VACM context selection.
155    /// Most deployments use empty string (default).
156    pub context_name: Option<String>,
157    /// Pre-computed master keys for caching.
158    /// When set, passwords are ignored and keys are derived from master keys.
159    pub master_keys: Option<MasterKeys>,
160}
161
162/// Builder for `SNMPv3` USM authentication.
163///
164/// The [`Debug`] implementation redacts the authentication and privacy
165/// passwords so that credentials are not leaked through logs or diagnostics.
166pub struct UsmBuilder {
167    username: String,
168    auth: Option<(AuthProtocol, String)>,
169    privacy: Option<(PrivProtocol, String)>,
170    context_name: Option<String>,
171    master_keys: Option<MasterKeys>,
172}
173
174impl UsmBuilder {
175    /// Create a new USM builder with the given username.
176    ///
177    /// # Example
178    ///
179    /// ```rust
180    /// use async_snmp::Auth;
181    ///
182    /// let builder = Auth::usm("admin");
183    /// ```
184    pub fn new(username: impl Into<String>) -> Self {
185        Self {
186            username: username.into(),
187            auth: None,
188            privacy: None,
189            context_name: None,
190            master_keys: None,
191        }
192    }
193
194    /// Add authentication (authNoPriv or authPriv).
195    ///
196    /// This method performs the full key derivation (~850us for SHA-256) when
197    /// the client connects. When polling many engines with shared credentials,
198    /// consider using [`with_master_keys`](Self::with_master_keys) instead.
199    ///
200    /// # Supported Protocols
201    ///
202    /// - `AuthProtocol::Md5` - HMAC-MD5-96 (legacy)
203    /// - `AuthProtocol::Sha1` - HMAC-SHA-96 (legacy)
204    /// - `AuthProtocol::Sha224` - HMAC-SHA-224
205    /// - `AuthProtocol::Sha256` - HMAC-SHA-256
206    /// - `AuthProtocol::Sha384` - HMAC-SHA-384
207    /// - `AuthProtocol::Sha512` - HMAC-SHA-512
208    ///
209    /// # Example
210    ///
211    /// ```rust
212    /// use async_snmp::{Auth, AuthProtocol};
213    ///
214    /// let auth: Auth = Auth::usm("admin")
215    ///     .auth(AuthProtocol::Sha256, "mypassword")
216    ///     .into();
217    /// ```
218    #[must_use]
219    pub fn auth(mut self, protocol: AuthProtocol, password: impl Into<String>) -> Self {
220        self.auth = Some((protocol, password.into()));
221        self
222    }
223
224    /// Add privacy/encryption (authPriv).
225    ///
226    /// Privacy requires authentication; this is validated at connection time.
227    ///
228    /// # Supported Protocols
229    ///
230    /// - `PrivProtocol::Des` - DES-CBC (legacy, insecure)
231    /// - `PrivProtocol::Aes128` - AES-128-CFB
232    /// - `PrivProtocol::Aes192` - AES-192-CFB
233    /// - `PrivProtocol::Aes256` - AES-256-CFB
234    ///
235    /// # Example
236    ///
237    /// ```rust
238    /// use async_snmp::{Auth, AuthProtocol, PrivProtocol};
239    ///
240    /// let auth: Auth = Auth::usm("admin")
241    ///     .auth(AuthProtocol::Sha256, "authpassword")
242    ///     .privacy(PrivProtocol::Aes128, "privpassword")
243    ///     .into();
244    /// ```
245    #[must_use]
246    pub fn privacy(mut self, protocol: PrivProtocol, password: impl Into<String>) -> Self {
247        self.privacy = Some((protocol, password.into()));
248        self
249    }
250
251    /// Use pre-computed master keys for authentication and privacy.
252    ///
253    /// This is the efficient path when polling many engines with shared
254    /// credentials. The expensive password-to-key derivation
255    /// (~850μs) is done once when creating the [`MasterKeys`], and only the
256    /// cheap localization (~1μs) is performed per engine.
257    ///
258    /// When master keys are set, the [`auth`](Self::auth) and
259    /// [`privacy`](Self::privacy) methods are ignored.
260    ///
261    /// # Example
262    ///
263    /// ```rust
264    /// use async_snmp::{Auth, AuthProtocol, PrivProtocol, MasterKeys};
265    ///
266    /// // Derive master keys once
267    /// let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpassword").unwrap()
268    ///     .with_privacy(PrivProtocol::Aes128, b"privpassword").unwrap();
269    ///
270    /// // Use with multiple clients
271    /// let auth: Auth = Auth::usm("admin")
272    ///     .with_master_keys(master_keys)
273    ///     .into();
274    /// ```
275    #[must_use]
276    pub fn with_master_keys(mut self, master_keys: MasterKeys) -> Self {
277        self.master_keys = Some(master_keys);
278        self
279    }
280
281    /// Set the `SNMPv3` context name for VACM context selection.
282    ///
283    /// The context name allows selecting different MIB views on the same agent.
284    /// Most deployments use empty string (default).
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use async_snmp::{Auth, AuthProtocol};
290    ///
291    /// let auth: Auth = Auth::usm("admin")
292    ///     .auth(AuthProtocol::Sha256, "password")
293    ///     .context_name("vlan100")
294    ///     .into();
295    /// ```
296    #[must_use]
297    pub fn context_name(mut self, name: impl Into<String>) -> Self {
298        self.context_name = Some(name.into());
299        self
300    }
301}
302
303impl From<UsmBuilder> for Auth {
304    fn from(b: UsmBuilder) -> Auth {
305        Auth::Usm(UsmAuth {
306            username: b.username,
307            auth_protocol: b
308                .master_keys
309                .as_ref()
310                .map(super::super::v3::auth::MasterKeys::auth_protocol)
311                .or(b.auth.as_ref().map(|(p, _)| *p)),
312            auth_password: b.auth.map(|(_, pw)| pw),
313            priv_protocol: b
314                .master_keys
315                .as_ref()
316                .and_then(super::super::v3::auth::MasterKeys::priv_protocol)
317                .or(b.privacy.as_ref().map(|(p, _)| *p)),
318            priv_password: b.privacy.map(|(_, pw)| pw),
319            context_name: b.context_name,
320            master_keys: b.master_keys,
321        })
322    }
323}
324
325/// Placeholder printed in place of a redacted secret value.
326const REDACTED: &str = "[REDACTED]";
327
328/// Formats an `Option<String>` secret as either `None` or a redacted marker,
329/// never printing the underlying value.
330fn redact_opt(value: &Option<String>) -> &'static str {
331    match value {
332        Some(_) => REDACTED,
333        None => "None",
334    }
335}
336
337impl std::fmt::Debug for Auth {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        match self {
340            Auth::Community { version, .. } => f
341                .debug_struct("Auth::Community")
342                .field("version", version)
343                .field("community", &REDACTED)
344                .finish(),
345            Auth::Usm(usm) => f.debug_tuple("Auth::Usm").field(usm).finish(),
346        }
347    }
348}
349
350impl std::fmt::Debug for UsmAuth {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        f.debug_struct("UsmAuth")
353            .field("username", &self.username)
354            .field("auth_protocol", &self.auth_protocol)
355            .field("auth_password", &redact_opt(&self.auth_password))
356            .field("priv_protocol", &self.priv_protocol)
357            .field("priv_password", &redact_opt(&self.priv_password))
358            .field("context_name", &self.context_name)
359            .field("master_keys", &self.master_keys)
360            .finish()
361    }
362}
363
364impl std::fmt::Debug for UsmBuilder {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.debug_struct("UsmBuilder")
367            .field("username", &self.username)
368            .field(
369                "auth",
370                &self.auth.as_ref().map(|(protocol, _)| (protocol, REDACTED)),
371            )
372            .field(
373                "privacy",
374                &self
375                    .privacy
376                    .as_ref()
377                    .map(|(protocol, _)| (protocol, REDACTED)),
378            )
379            .field("context_name", &self.context_name)
380            .field("master_keys", &self.master_keys)
381            .finish()
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_default_auth() {
391        let auth = Auth::default();
392        match auth {
393            Auth::Community { version, community } => {
394                assert_eq!(version, CommunityVersion::V2c);
395                assert_eq!(community, "public");
396            }
397            Auth::Usm(_) => panic!("expected Community variant"),
398        }
399    }
400
401    #[test]
402    fn test_v1_auth() {
403        let auth = Auth::v1("private");
404        match auth {
405            Auth::Community { version, community } => {
406                assert_eq!(version, CommunityVersion::V1);
407                assert_eq!(community, "private");
408            }
409            Auth::Usm(_) => panic!("expected Community variant"),
410        }
411    }
412
413    #[test]
414    fn test_v2c_auth() {
415        let auth = Auth::v2c("secret");
416        match auth {
417            Auth::Community { version, community } => {
418                assert_eq!(version, CommunityVersion::V2c);
419                assert_eq!(community, "secret");
420            }
421            Auth::Usm(_) => panic!("expected Community variant"),
422        }
423    }
424
425    #[test]
426    fn test_community_version_default() {
427        let version = CommunityVersion::default();
428        assert_eq!(version, CommunityVersion::V2c);
429    }
430
431    #[test]
432    fn test_usm_no_auth_no_priv() {
433        let auth: Auth = Auth::usm("readonly").into();
434        match auth {
435            Auth::Usm(usm) => {
436                assert_eq!(usm.username, "readonly");
437                assert!(usm.auth_protocol.is_none());
438                assert!(usm.auth_password.is_none());
439                assert!(usm.priv_protocol.is_none());
440                assert!(usm.priv_password.is_none());
441                assert!(usm.context_name.is_none());
442            }
443            Auth::Community { .. } => panic!("expected Usm variant"),
444        }
445    }
446
447    #[test]
448    fn test_usm_auth_no_priv() {
449        let auth: Auth = Auth::usm("admin")
450            .auth(AuthProtocol::Sha256, "authpass123")
451            .into();
452        match auth {
453            Auth::Usm(usm) => {
454                assert_eq!(usm.username, "admin");
455                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha256));
456                assert_eq!(usm.auth_password, Some("authpass123".to_string()));
457                assert!(usm.priv_protocol.is_none());
458                assert!(usm.priv_password.is_none());
459            }
460            Auth::Community { .. } => panic!("expected Usm variant"),
461        }
462    }
463
464    #[test]
465    fn test_usm_auth_priv() {
466        let auth: Auth = Auth::usm("admin")
467            .auth(AuthProtocol::Sha256, "authpass")
468            .privacy(PrivProtocol::Aes128, "privpass")
469            .into();
470        match auth {
471            Auth::Usm(usm) => {
472                assert_eq!(usm.username, "admin");
473                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha256));
474                assert_eq!(usm.auth_password, Some("authpass".to_string()));
475                assert_eq!(usm.priv_protocol, Some(PrivProtocol::Aes128));
476                assert_eq!(usm.priv_password, Some("privpass".to_string()));
477            }
478            Auth::Community { .. } => panic!("expected Usm variant"),
479        }
480    }
481
482    #[test]
483    fn test_usm_with_context_name() {
484        let auth: Auth = Auth::usm("admin")
485            .auth(AuthProtocol::Sha256, "authpass")
486            .context_name("vlan100")
487            .into();
488        match auth {
489            Auth::Usm(usm) => {
490                assert_eq!(usm.username, "admin");
491                assert_eq!(usm.context_name, Some("vlan100".to_string()));
492            }
493            Auth::Community { .. } => panic!("expected Usm variant"),
494        }
495    }
496
497    #[test]
498    fn test_usm_builder_chaining() {
499        // Verify all methods can be chained
500        let auth: Auth = Auth::usm("user")
501            .auth(AuthProtocol::Sha512, "auth")
502            .privacy(PrivProtocol::Aes256, "priv")
503            .context_name("ctx")
504            .into();
505
506        match auth {
507            Auth::Usm(usm) => {
508                assert_eq!(usm.username, "user");
509                assert_eq!(usm.auth_protocol, Some(AuthProtocol::Sha512));
510                assert_eq!(usm.auth_password, Some("auth".to_string()));
511                assert_eq!(usm.priv_protocol, Some(PrivProtocol::Aes256));
512                assert_eq!(usm.priv_password, Some("priv".to_string()));
513                assert_eq!(usm.context_name, Some("ctx".to_string()));
514            }
515            Auth::Community { .. } => panic!("expected Usm variant"),
516        }
517    }
518
519    #[test]
520    fn test_debug_redacts_secrets() {
521        // Community string must not appear in Debug output.
522        let community = Auth::v2c("supersecretcommunity");
523        let rendered = format!("{community:?}");
524        assert!(!rendered.contains("supersecretcommunity"), "{rendered}");
525        assert!(rendered.contains("[REDACTED]"), "{rendered}");
526
527        // USM auth/priv passwords must not appear in Debug output.
528        let builder = Auth::usm("admin")
529            .auth(AuthProtocol::Sha256, "authpassword123")
530            .privacy(PrivProtocol::Aes128, "privpassword456")
531            .context_name("vlan100");
532        let builder_rendered = format!("{builder:?}");
533        assert!(
534            !builder_rendered.contains("authpassword123"),
535            "{builder_rendered}"
536        );
537        assert!(
538            !builder_rendered.contains("privpassword456"),
539            "{builder_rendered}"
540        );
541        // Non-secret fields remain visible.
542        assert!(builder_rendered.contains("admin"), "{builder_rendered}");
543        assert!(builder_rendered.contains("vlan100"), "{builder_rendered}");
544
545        let usm: Auth = builder.into();
546        let usm_rendered = format!("{usm:?}");
547        assert!(!usm_rendered.contains("authpassword123"), "{usm_rendered}");
548        assert!(!usm_rendered.contains("privpassword456"), "{usm_rendered}");
549        assert!(usm_rendered.contains("[REDACTED]"), "{usm_rendered}");
550        assert!(usm_rendered.contains("admin"), "{usm_rendered}");
551    }
552}