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}