1use bytes::Bytes;
7
8use crate::message::SecurityLevel;
9use crate::v3::{AuthProtocol, LocalizedKey, PrivKey, PrivProtocol};
10
11#[derive(Clone)]
23pub struct UsmConfig {
24 pub username: Bytes,
26 pub auth: Option<(AuthProtocol, Vec<u8>)>,
28 pub privacy: Option<(PrivProtocol, Vec<u8>)>,
30 pub context_name: Bytes,
32 pub master_keys: Option<crate::v3::MasterKeys>,
34}
35
36impl UsmConfig {
37 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 #[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 #[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 #[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 #[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 pub fn security_level(&self) -> SecurityLevel {
82 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 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 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 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 pub fn derive_keys(&self, engine_id: &[u8]) -> crate::v3::CryptoResult<DerivedKeys> {
166 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 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#[derive(Debug)]
228pub struct DerivedKeys {
229 pub auth_key: Option<LocalizedKey>,
231 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 #[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 config.precompute_master_keys();
316 assert!(config.master_keys.is_some());
317 }
318
319 #[test]
322 fn test_precompute_master_keys_preserves_derivation() {
323 let engine_id = b"\x80\x00\x00\x00\x01test-engine";
324
325 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 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 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 #[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}