Skip to main content

async_snmp/notification/
types.rs

1//! USM configuration types for `SNMPv3` authentication.
2//!
3//! These types store authentication and privacy settings for `SNMPv3` operations,
4//! used by both the client and notification receiver.
5
6use bytes::Bytes;
7
8use crate::message::SecurityLevel;
9use crate::v3::{AuthProtocol, LocalizedKey, PrivKey, PrivProtocol};
10
11/// USM user credentials for `SNMPv3` authentication.
12///
13/// Stores the credentials needed for authenticated and/or encrypted communication.
14/// Keys are derived when the engine ID is discovered.
15///
16/// # Master Key Caching
17///
18/// When polling many engines with shared credentials, use
19/// [`MasterKeys`](crate::MasterKeys) to cache the expensive password-to-key
20/// derivation. When `master_keys` is set, passwords are ignored and keys are
21/// derived from the cached master keys.
22#[derive(Clone)]
23pub struct UsmConfig {
24    /// Username for USM authentication
25    pub username: Bytes,
26    /// Authentication protocol and password
27    pub auth: Option<(AuthProtocol, Vec<u8>)>,
28    /// Privacy protocol and password
29    pub privacy: Option<(PrivProtocol, Vec<u8>)>,
30    /// `SNMPv3` context name for VACM context selection.
31    pub context_name: Bytes,
32    /// Pre-computed master keys for efficient key derivation
33    pub master_keys: Option<crate::v3::MasterKeys>,
34}
35
36impl UsmConfig {
37    /// Create a new USM config with just a username (noAuthNoPriv).
38    pub fn new(username: impl Into<Bytes>) -> Self {
39        Self {
40            username: username.into(),
41            auth: None,
42            privacy: None,
43            context_name: Bytes::new(),
44            master_keys: None,
45        }
46    }
47
48    /// Add authentication (authNoPriv or authPriv).
49    #[must_use]
50    pub fn auth(mut self, protocol: AuthProtocol, password: impl AsRef<[u8]>) -> Self {
51        self.auth = Some((protocol, password.as_ref().to_vec()));
52        self
53    }
54
55    /// Add privacy/encryption (authPriv).
56    #[must_use]
57    pub fn privacy(mut self, protocol: PrivProtocol, password: impl AsRef<[u8]>) -> Self {
58        self.privacy = Some((protocol, password.as_ref().to_vec()));
59        self
60    }
61
62    /// Set the `SNMPv3` context name for scoped PDUs.
63    #[must_use]
64    pub fn context_name(mut self, context_name: impl Into<Bytes>) -> Self {
65        self.context_name = context_name.into();
66        self
67    }
68
69    /// Use pre-computed master keys for efficient key derivation.
70    ///
71    /// When set, passwords are ignored and keys are derived from the cached
72    /// master keys. This avoids the expensive ~850us password expansion for
73    /// each engine.
74    #[must_use]
75    pub fn with_master_keys(mut self, master_keys: crate::v3::MasterKeys) -> Self {
76        self.master_keys = Some(master_keys);
77        self
78    }
79
80    /// Get the security level based on configured auth/privacy.
81    pub fn security_level(&self) -> SecurityLevel {
82        // Check master_keys first, then fall back to auth/privacy
83        if let Some(ref master_keys) = self.master_keys {
84            if master_keys.priv_protocol().is_some() {
85                return SecurityLevel::AuthPriv;
86            }
87            return SecurityLevel::AuthNoPriv;
88        }
89
90        match (&self.auth, &self.privacy) {
91            (None, _) => SecurityLevel::NoAuthNoPriv,
92            (Some(_), None) => SecurityLevel::AuthNoPriv,
93            (Some(_), Some(_)) => SecurityLevel::AuthPriv,
94        }
95    }
96
97    /// Validate the credential configuration.
98    ///
99    /// Rejects privacy without authentication: RFC 3411 requires
100    /// authentication whenever privacy is selected. Mirrors the client
101    /// builder validation so that agent and notification-receiver USM users
102    /// cannot be silently downgraded to noAuthNoPriv with the privacy key
103    /// dropped.
104    pub(crate) fn validate(&self) -> crate::error::Result<()> {
105        if self.privacy.is_some() && self.auth.is_none() {
106            return Err(
107                crate::error::Error::Config("privacy requires authentication".into()).boxed(),
108            );
109        }
110        Ok(())
111    }
112
113    /// Precompute and cache master keys from any configured passwords.
114    ///
115    /// The password-to-master expansion (RFC 3414 Section 2.6, a 1 MiB buffer
116    /// hash) is the expensive part of key derivation. Performing it once at
117    /// configuration time — rather than on every inbound packet in
118    /// [`derive_keys`](Self::derive_keys) — removes an unauthenticated CPU
119    /// amplification vector: without it, an attacker can force a fresh
120    /// password expansion per spoofed message (a Report/discovery for a known
121    /// username triggers derivation before authentication is verified).
122    ///
123    /// After this call the localized-key path localizes from the cached master
124    /// keys (~1us) instead of re-expanding the password. Localized results are
125    /// identical to the password path (the master key is the same value the
126    /// password would have produced). If `master_keys` is already set, or no
127    /// authentication is configured, this is a no-op. Best-effort: if the
128    /// crypto backend rejects a password (e.g. too short), the config is left
129    /// unchanged so the original password path (and its error) is preserved.
130    pub(crate) fn precompute_master_keys(&mut self) {
131        if self.master_keys.is_some() {
132            return;
133        }
134        let Some((auth_protocol, auth_password)) = self.auth.as_ref() else {
135            return;
136        };
137        let master_keys = match crate::v3::MasterKeys::new(*auth_protocol, auth_password) {
138            Ok(mk) => mk,
139            Err(_) => return,
140        };
141        let master_keys = match &self.privacy {
142            Some((priv_protocol, priv_password)) => {
143                // Same password reuses the auth master; a different password
144                // needs its own expansion. Both yield identical localized
145                // privacy keys.
146                if priv_password == auth_password {
147                    master_keys.with_privacy_same_password(*priv_protocol)
148                } else {
149                    match master_keys.with_privacy(*priv_protocol, priv_password) {
150                        Ok(mk) => mk,
151                        Err(_) => return,
152                    }
153                }
154            }
155            None => master_keys,
156        };
157        self.master_keys = Some(master_keys);
158    }
159
160    /// Derive localized keys for a specific engine ID.
161    ///
162    /// If master keys are configured, uses the cached master keys for efficient
163    /// localization (~1us). Otherwise, performs full password-to-key derivation
164    /// (~850us for SHA-256).
165    pub fn derive_keys(&self, engine_id: &[u8]) -> crate::v3::CryptoResult<DerivedKeys> {
166        // Use master keys if available (efficient path)
167        if let Some(ref master_keys) = self.master_keys {
168            tracing::trace!(target: "async_snmp::client", { engine_id_len = engine_id.len(), auth_protocol = ?master_keys.auth_protocol(), priv_protocol = ?master_keys.priv_protocol() }, "localizing from cached master keys");
169            let (auth_key, priv_key) = master_keys.localize(engine_id)?;
170            tracing::trace!(target: "async_snmp::client", "key localization complete");
171            return Ok(DerivedKeys {
172                auth_key: Some(auth_key),
173                priv_key,
174            });
175        }
176
177        // Fall back to password-based derivation
178        tracing::trace!(target: "async_snmp::client", { engine_id_len = engine_id.len(), has_auth = self.auth.is_some(), has_priv = self.privacy.is_some() }, "deriving localized keys from passwords");
179
180        let auth_key = self.auth.as_ref().map(|(protocol, password)| {
181            tracing::trace!(target: "async_snmp::client", { auth_protocol = ?protocol }, "deriving auth key");
182            LocalizedKey::from_password(*protocol, password, engine_id)
183        }).transpose()?;
184
185        let priv_key = match (&self.auth, &self.privacy) {
186            (Some((auth_protocol, _)), Some((priv_protocol, priv_password))) => {
187                tracing::trace!(target: "async_snmp::client", { priv_protocol = ?priv_protocol }, "deriving privacy key");
188                Some(PrivKey::from_password(
189                    *auth_protocol,
190                    *priv_protocol,
191                    priv_password,
192                    engine_id,
193                )?)
194            }
195            _ => None,
196        };
197
198        tracing::trace!(target: "async_snmp::client", "key derivation complete");
199        Ok(DerivedKeys { auth_key, priv_key })
200    }
201}
202
203impl std::fmt::Debug for UsmConfig {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.debug_struct("UsmConfig")
206            .field("username", &String::from_utf8_lossy(&self.username))
207            .field("auth", &self.auth.as_ref().map(|(p, _)| p))
208            .field("privacy", &self.privacy.as_ref().map(|(p, _)| p))
209            .field("context_name", &String::from_utf8_lossy(&self.context_name))
210            .field(
211                "master_keys",
212                &self.master_keys.as_ref().map(|mk| {
213                    format!(
214                        "MasterKeys({:?}, {:?})",
215                        mk.auth_protocol(),
216                        mk.priv_protocol()
217                    )
218                }),
219            )
220            .finish()
221    }
222}
223
224/// Derived keys for a specific engine ID.
225///
226/// Used internally for V3 authentication in both client and notification receiver.
227#[derive(Debug)]
228pub struct DerivedKeys {
229    /// Localized authentication key
230    pub auth_key: Option<LocalizedKey>,
231    /// Privacy key
232    pub priv_key: Option<PrivKey>,
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_usm_user_config_no_auth() {
241        let config = UsmConfig::new(Bytes::from_static(b"testuser"));
242        assert_eq!(config.security_level(), SecurityLevel::NoAuthNoPriv);
243        assert!(config.auth.is_none());
244        assert!(config.privacy.is_none());
245    }
246
247    #[test]
248    fn test_usm_user_config_auth_only() {
249        let config = UsmConfig::new(Bytes::from_static(b"testuser"))
250            .auth(AuthProtocol::Sha1, b"password123");
251        assert_eq!(config.security_level(), SecurityLevel::AuthNoPriv);
252        assert!(config.auth.is_some());
253        assert!(config.privacy.is_none());
254        assert!(config.context_name.is_empty());
255    }
256
257    #[test]
258    fn test_usm_user_config_auth_priv() {
259        let config = UsmConfig::new(Bytes::from_static(b"testuser"))
260            .auth(AuthProtocol::Sha256, b"authpass")
261            .privacy(PrivProtocol::Aes128, b"privpass");
262        assert_eq!(config.security_level(), SecurityLevel::AuthPriv);
263        assert!(config.auth.is_some());
264        assert!(config.privacy.is_some());
265    }
266
267    #[test]
268    fn test_usm_user_config_context_name() {
269        let config = UsmConfig::new(Bytes::from_static(b"testuser")).context_name("ctx");
270        assert_eq!(config.context_name.as_ref(), b"ctx");
271    }
272
273    #[test]
274    fn test_usm_user_config_derive_keys() {
275        let config = UsmConfig::new(Bytes::from_static(b"testuser"))
276            .auth(AuthProtocol::Sha1, b"password123");
277
278        let engine_id = b"test-engine-id";
279        let keys = config.derive_keys(engine_id).unwrap();
280
281        assert!(keys.auth_key.is_some());
282        assert!(keys.priv_key.is_none());
283    }
284
285    #[test]
286    fn test_usm_user_config_derive_keys_with_privacy() {
287        let config = UsmConfig::new(Bytes::from_static(b"testuser"))
288            .auth(AuthProtocol::Sha256, b"authpass")
289            .privacy(PrivProtocol::Aes128, b"privpass");
290
291        let engine_id = b"test-engine-id";
292        let keys = config.derive_keys(engine_id).unwrap();
293
294        assert!(keys.auth_key.is_some());
295        assert!(keys.priv_key.is_some());
296    }
297
298    /// Precomputing master keys populates the cache, so subsequent
299    /// `derive_keys` calls take the master-key localization path instead of
300    /// re-running the 1 MiB password expansion (the CPU-amplification vector).
301    #[test]
302    fn test_precompute_master_keys_populates_cache() {
303        let mut config = UsmConfig::new(Bytes::from_static(b"testuser"))
304            .auth(AuthProtocol::Sha256, b"authpass")
305            .privacy(PrivProtocol::Aes128, b"privpass");
306        assert!(config.master_keys.is_none());
307
308        config.precompute_master_keys();
309        assert!(
310            config.master_keys.is_some(),
311            "precompute must cache master keys so per-packet derivation avoids password expansion"
312        );
313
314        // Idempotent: a second call is a no-op and keeps the cache.
315        config.precompute_master_keys();
316        assert!(config.master_keys.is_some());
317    }
318
319    /// The cached (master-key) path and the uncached (password) path must
320    /// derive identical localized keys, for both auth-only and authPriv.
321    #[test]
322    fn test_precompute_master_keys_preserves_derivation() {
323        let engine_id = b"\x80\x00\x00\x00\x01test-engine";
324
325        // authNoPriv
326        let uncached =
327            UsmConfig::new(Bytes::from_static(b"u")).auth(AuthProtocol::Sha256, b"authpass");
328        let mut cached = uncached.clone();
329        cached.precompute_master_keys();
330        let a = uncached.derive_keys(engine_id).unwrap();
331        let b = cached.derive_keys(engine_id).unwrap();
332        assert_eq!(
333            a.auth_key.as_ref().map(AsRef::as_ref),
334            b.auth_key.as_ref().map(AsRef::as_ref),
335            "auth key must match between password and master-key paths"
336        );
337
338        // authPriv, distinct auth/priv passwords
339        let uncached = UsmConfig::new(Bytes::from_static(b"u"))
340            .auth(AuthProtocol::Sha1, b"authpassword")
341            .privacy(PrivProtocol::Aes128, b"privpassword");
342        let mut cached = uncached.clone();
343        cached.precompute_master_keys();
344        let a = uncached.derive_keys(engine_id).unwrap();
345        let b = cached.derive_keys(engine_id).unwrap();
346        assert_eq!(
347            a.auth_key.as_ref().map(AsRef::as_ref),
348            b.auth_key.as_ref().map(AsRef::as_ref),
349        );
350        assert!(a.priv_key.is_some() && b.priv_key.is_some());
351
352        // authPriv, same auth/priv password (with_privacy_same_password path)
353        let uncached = UsmConfig::new(Bytes::from_static(b"u"))
354            .auth(AuthProtocol::Sha1, b"sharedpassword")
355            .privacy(PrivProtocol::Aes128, b"sharedpassword");
356        let mut cached = uncached.clone();
357        cached.precompute_master_keys();
358        let a = uncached.derive_keys(engine_id).unwrap();
359        let b = cached.derive_keys(engine_id).unwrap();
360        assert_eq!(
361            a.auth_key.as_ref().map(AsRef::as_ref),
362            b.auth_key.as_ref().map(AsRef::as_ref),
363        );
364    }
365
366    /// A password too short for the crypto backend leaves the config on the
367    /// original password path (no silent success, error preserved for later).
368    #[test]
369    fn test_precompute_master_keys_short_password_is_noop() {
370        let mut config =
371            UsmConfig::new(Bytes::from_static(b"u")).auth(AuthProtocol::Sha256, b"short");
372        config.precompute_master_keys();
373        assert!(
374            config.master_keys.is_none(),
375            "a rejected password must not populate the cache"
376        );
377    }
378}