Skip to main content

async_snmp/client/
v3.rs

1//! SNMPv3-specific client functionality.
2//!
3//! This module contains V3 security configuration, key derivation, engine discovery,
4//! and V3 message building/handling.
5
6use crate::ber::Decoder;
7use crate::error::internal::{AuthErrorKind, CryptoErrorKind};
8use crate::error::{Error, Result};
9use crate::format::hex;
10use crate::message::{RawMsgData, RawV3Message, ScopedPdu, SecurityLevel, V3Message};
11use crate::pdu::{Pdu, PduType};
12use crate::transport::Transport;
13use crate::v3::{
14    EngineCache, EngineState, ReportStatus, UsmSecurityParams, auth::verify_message,
15    classify_report,
16};
17use bytes::Bytes;
18use std::net::SocketAddr;
19use std::time::Instant;
20use tracing::{Span, instrument};
21
22use super::{Client, ClientEngine};
23
24struct EncodedV3Request {
25    data: Vec<u8>,
26    context_engine_id: Bytes,
27    context_name: Bytes,
28}
29
30struct PacketLocalEngineTime {
31    engine_id: Bytes,
32    boots: u32,
33    time: u32,
34}
35
36fn check_and_update_engine_timeliness(
37    state: &mut EngineState,
38    cache: Option<&EngineCache>,
39    target: SocketAddr,
40    engine_id: &[u8],
41    msg_boots: u32,
42    msg_time: u32,
43) -> bool {
44    if let Some(cache) = cache
45        && let Some((timely, cached_state)) =
46            cache.check_and_update_timeliness(&target, state, engine_id, msg_boots, msg_time)
47    {
48        state.merge_from(&cached_state);
49        return timely;
50    }
51
52    let timely = state.check_and_update_timeliness(msg_boots, msg_time);
53    if timely && let Some(cache) = cache {
54        cache.insert(target, state.clone());
55    }
56    timely
57}
58
59// V3-specific Client implementation
60impl<T: Transport> Client<T> {
61    /// Ensure engine ID is discovered for V3 operations.
62    #[instrument(level = "debug", skip(self), fields(snmp.target = %self.peer_addr()))]
63    pub(super) async fn ensure_engine_discovered(&self) -> Result<()> {
64        // Fast path: already discovered.
65        {
66            let engine = self
67                .inner
68                .engine
69                .read()
70                .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
71            if engine.is_some() {
72                return Ok(());
73            }
74        }
75
76        // Serialize concurrent discovery attempts. Only one task runs discovery
77        // at a time; the rest wait here and then take the fast path above.
78        let _guard = self.inner.discovery_lock.lock().await;
79        self.discover_engine_locked(false).await
80    }
81
82    /// Discover and replace the established authoritative engine.
83    ///
84    /// This is the intentional recovery path when a device at the target
85    /// address has been replaced or reconfigured with a new engine ID.
86    /// Discovery never replaces an established identity during ordinary
87    /// request processing. The current identity, localized keys, and shared
88    /// cache mapping remain usable until a fresh response has been strictly
89    /// validated and replacement keys have been derived. A failed or cancelled
90    /// rediscovery therefore leaves the previous generation intact.
91    ///
92    /// Client clones share a successful replacement because they share the
93    /// same live engine state. Independently constructed clients retain their
94    /// own established identity until explicitly rediscovered. For UDP,
95    /// source-address policy is controlled by
96    /// [`ClientBuilder::strict_source`](crate::ClientBuilder::strict_source) or
97    /// by the supplied transport handle.
98    pub async fn rediscover_engine(&self) -> Result<()> {
99        if !self.is_v3() {
100            return Err(Error::Config("engine discovery requires SNMPv3".into()).boxed());
101        }
102
103        let _guard = self.inner.discovery_lock.lock().await;
104        self.discover_engine_locked(true).await
105    }
106
107    /// Load or discover an engine while `discovery_lock` is held.
108    async fn discover_engine_locked(&self, replace_cached_identity: bool) -> Result<()> {
109        // Re-check after acquiring the lock: a previous waiter may have
110        // completed ordinary discovery while we were blocked. Explicit
111        // rediscovery always reaches the peer even with a live identity.
112        if !replace_cached_identity {
113            let engine = self
114                .inner
115                .engine
116                .read()
117                .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
118            if engine.is_some() {
119                return Ok(());
120            }
121        }
122
123        // Explicit rediscovery must reach the peer even if another client
124        // refreshes the previous target mapping while discovery is in progress.
125        if !replace_cached_identity
126            && let Some(cache) = &self.inner.engine_cache
127            && let Some(cached_state) = cache.get(&self.peer_addr())
128        {
129            tracing::debug!(target: "async_snmp::client", "using cached engine state");
130            let security = self
131                .inner
132                .config
133                .v3_security
134                .as_ref()
135                .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
136            let derived_keys = security
137                .derive_keys(&cached_state.engine_id)
138                .map_err(|e| Error::Config(e.to_string().into()).boxed())?;
139            let mut engine = self
140                .inner
141                .engine
142                .write()
143                .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
144            *engine = Some(ClientEngine {
145                state: cached_state,
146                derived_keys,
147            });
148            return Ok(());
149        }
150
151        // Perform discovery with retry (same policy as normal requests)
152        tracing::debug!(target: "async_snmp::client", "performing engine discovery");
153
154        let max_attempts = if self.inner.transport.is_reliable() {
155            0
156        } else {
157            self.inner.config.retry.max_attempts
158        };
159
160        let mut last_error: Option<Box<Error>> = None;
161        let mut response_data_opt: Option<(Bytes, SocketAddr, i32)> = None;
162
163        'discovery: for attempt in 0..=max_attempts {
164            if attempt > 0 {
165                tracing::debug!(target: "async_snmp::client", "retrying engine discovery");
166            }
167
168            let msg_id = self.next_request_id();
169            let discovery_msg = V3Message::discovery_request(msg_id);
170            let discovery_data = discovery_msg.encode();
171
172            self.inner
173                .transport
174                .register_request(msg_id, self.inner.config.timeout);
175
176            match self.inner.transport.request(&discovery_data, msg_id).await {
177                Ok((data, source)) => {
178                    response_data_opt = Some((data, source, msg_id));
179                    break 'discovery;
180                }
181                Err(e) if matches!(*e, Error::Timeout { .. }) => {
182                    last_error = Some(e);
183                    if attempt < max_attempts {
184                        let delay = self.inner.config.retry.compute_delay(attempt);
185                        if !delay.is_zero() {
186                            tracing::debug!(target: "async_snmp::client", { delay_ms = delay.as_millis() as u64 }, "backing off");
187                            tokio::time::sleep(delay).await;
188                        }
189                    }
190                    // fall thru to next loop iteration
191                }
192                Err(e) => return Err(e),
193            }
194        }
195
196        let (response_data, _source, expected_msg_id) = response_data_opt.ok_or_else(|| {
197            last_error.unwrap_or_else(|| {
198                Error::Timeout {
199                    target: self.peer_addr(),
200                    elapsed: std::time::Duration::ZERO,
201                    retries: max_attempts,
202                }
203                .boxed()
204            })
205        })?;
206
207        // Apply USM syntax/security processing and parse the scoped PDU before
208        // Message Processing Model correlation (RFC 3412 Section 7.2), then
209        // require the exact RFC 3414 discovery Report shape before adopting an
210        // unauthenticated engine-ID candidate.
211        let response = RawV3Message::decode(response_data)?;
212        let engine_state = self.validate_discovery_response(&response, expected_msg_id)?;
213        tracing::debug!(target: "async_snmp::client", { snmp.engine_id = %hex::Bytes(&engine_state.engine_id), snmp.msg_max_size = engine_state.msg_max_size }, "discovered engine identity");
214
215        let security = self
216            .inner
217            .config
218            .v3_security
219            .as_ref()
220            .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
221
222        // Prepare replacement keys before changing either the live generation
223        // or its cache mapping. Ordinary discovery must first resolve any
224        // canonical state already installed by another client sharing the
225        // cache, then derive keys for that identity.
226        let replacement_keys = if replace_cached_identity {
227            Some(
228                security
229                    .derive_keys(&engine_state.engine_id)
230                    .map_err(|e| Error::Config(e.to_string().into()).boxed())?,
231            )
232        } else {
233            None
234        };
235
236        if let Some(derived_keys) = replacement_keys {
237            // Lock the live generation before publishing its cache mapping.
238            // This matches the live-then-cache lock order used by authenticated
239            // timeliness updates and prevents clones from observing a new
240            // mapping alongside the old identity-localized keys. Reloading the
241            // cache state also adopts trusted time already shared by another
242            // target for the newly discovered engine ID.
243            let mut engine = self
244                .inner
245                .engine
246                .write()
247                .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
248            let engine_state = if let Some(cache) = &self.inner.engine_cache {
249                cache.replace_target(self.peer_addr(), engine_state)?
250            } else {
251                engine_state
252            };
253            *engine = Some(ClientEngine {
254                state: engine_state,
255                derived_keys,
256            });
257            return Ok(());
258        }
259
260        // A concurrent client sharing this cache may already have installed a
261        // newer identity/time generation. Merge ordinary discovery without
262        // replacing an active target mapping, then derive keys for the
263        // canonical identity.
264        let engine_state = if let Some(cache) = &self.inner.engine_cache {
265            cache.insert(self.peer_addr(), engine_state.clone());
266            cache.get(&self.peer_addr()).unwrap_or(engine_state)
267        } else {
268            engine_state
269        };
270        let derived_keys = security
271            .derive_keys(&engine_state.engine_id)
272            .map_err(|e| Error::Config(e.to_string().into()).boxed())?;
273        let mut engine = self
274            .inner
275            .engine
276            .write()
277            .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
278        *engine = Some(ClientEngine {
279            state: engine_state,
280            derived_keys,
281        });
282
283        Ok(())
284    }
285
286    fn validate_discovery_response(
287        &self,
288        response: &RawV3Message,
289        expected_msg_id: i32,
290    ) -> Result<EngineState> {
291        let malformed = || {
292            Error::MalformedResponse {
293                target: self.peer_addr(),
294            }
295            .boxed()
296        };
297
298        // Discovery is deliberately unauthenticated. Authenticated or private
299        // messages are not an alternative discovery response shape. RFC 3412
300        // requires a received Report's reportableFlag to be treated as zero,
301        // so that bit does not affect acceptance.
302        if response.security_level() != SecurityLevel::NoAuthNoPriv {
303            return Err(malformed());
304        }
305
306        // Decode and validate the candidate before parsing the plaintext PDU,
307        // preserving USM-before-scoped-PDU processing order. Boots/time are
308        // syntactically decoded but discarded as unauthenticated input.
309        let usm = UsmSecurityParams::decode(response.security_params.clone())?;
310        let engine_state = crate::v3::parse_discovery_response_with_limits(
311            &response.security_params,
312            response.global_data.msg_max_size as u32,
313            self.inner.transport.max_message_size(),
314        )?;
315        if !usm.username.is_empty() || !usm.auth_params.is_empty() || !usm.priv_params.is_empty() {
316            return Err(malformed());
317        }
318
319        let RawMsgData::Plaintext(bytes) = &response.msg_data else {
320            return Err(malformed());
321        };
322        let mut decoder = Decoder::with_target(bytes.clone(), self.peer_addr());
323        let scoped_pdu = ScopedPdu::decode(&mut decoder)?;
324
325        // Bind the Internal-class Report to the exact outstanding discovery
326        // attempt only after Security Model processing and scoped-PDU parsing.
327        if response.global_data.msg_id != expected_msg_id {
328            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), expected_msg_id, actual_msg_id = response.global_data.msg_id }, "msgID mismatch in discovery response");
329            return Err(malformed());
330        }
331
332        if scoped_pdu.context_engine_id != engine_state.engine_id
333            || !scoped_pdu.context_name.is_empty()
334        {
335            return Err(malformed());
336        }
337
338        if !matches!(
339            classify_report(&scoped_pdu.pdu),
340            Ok(ReportStatus::UnknownEngineId { .. })
341        ) {
342            return Err(malformed());
343        }
344
345        Ok(engine_state)
346    }
347
348    fn refresh_engine_from_cache(&self) -> Result<()> {
349        let Some(cache) = &self.inner.engine_cache else {
350            return Ok(());
351        };
352        let Some(cached_state) = cache.get(&self.peer_addr()) else {
353            return Ok(());
354        };
355        let mut engine = self
356            .inner
357            .engine
358            .write()
359            .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
360        if let Some(engine) = engine.as_mut() {
361            engine.state.merge_from(&cached_state);
362        }
363        Ok(())
364    }
365
366    /// Build and encode a V3 message with authentication and/or encryption.
367    ///
368    /// The `msg_id` parameter is separate from `pdu.request_id` per RFC 3412
369    /// Section 6.2: retransmissions SHOULD use a new msgID for each attempt.
370    fn build_v3_message(
371        &self,
372        pdu: &Pdu,
373        msg_id: i32,
374        engine_time_override: Option<&PacketLocalEngineTime>,
375    ) -> Result<EncodedV3Request> {
376        let security = self
377            .inner
378            .config
379            .v3_security
380            .as_ref()
381            .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
382
383        self.refresh_engine_from_cache()?;
384        let engine = self
385            .inner
386            .engine
387            .read()
388            .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
389        let engine = engine
390            .as_ref()
391            .ok_or_else(|| Error::Config("engine not discovered".into()).boxed())?;
392
393        let context_engine_id = engine.state.engine_id.clone();
394        let context_name = security.configured_context_name().clone();
395        let (engine_boots, engine_time) = if let Some(engine_time) = engine_time_override {
396            // Keep the untrusted tuple bound to the engine generation that
397            // supplied it. Concurrent explicit rediscovery must not combine
398            // one engine's tuple with another engine's identity/localized keys.
399            if engine_time.engine_id != engine.state.engine_id {
400                return Err(Error::MalformedResponse {
401                    target: self.peer_addr(),
402                }
403                .boxed());
404            }
405            (engine_time.boots, engine_time.time)
406        } else {
407            engine.state.estimated_boots_time()
408        };
409        let data = crate::v3::encode::encode_v3_message(
410            pdu,
411            msg_id,
412            &context_engine_id,
413            engine_boots,
414            engine_time,
415            security,
416            Some(&engine.derived_keys),
417            &self.inner.salt_counter,
418            true, // reportable=true for requests
419            // RFC 3412 Section 6.3: msgMaxSize advertises THIS sender's own
420            // receive capacity, not the remote's. `engine_state.msg_max_size`
421            // holds the remote's advertised limit (used to constrain our
422            // outbound size), so advertise the local transport capacity here.
423            self.inner.transport.max_message_size(),
424        )?;
425
426        Ok(EncodedV3Request {
427            data,
428            context_engine_id,
429            context_name,
430        })
431    }
432
433    /// Apply the received USM identity, capability, and authentication policy.
434    ///
435    /// The cached localized keys are valid only for the configured security
436    /// name and discovered authoritative engine. Bind the received parameters
437    /// to that tuple before using the keys, then perform RFC 3414 Step 5
438    /// capability checks before Step 6 HMAC verification.
439    fn verify_response_security(
440        &self,
441        response_data: &[u8],
442        response_usm: &UsmSecurityParams,
443        received_level: SecurityLevel,
444    ) -> Result<()> {
445        let security = self
446            .inner
447            .config
448            .v3_security
449            .as_ref()
450            .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
451
452        if response_usm.username != security.username() {
453            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr() }, "USM security name does not select the configured user");
454            return Err(Error::Auth {
455                target: self.peer_addr(),
456            }
457            .boxed());
458        }
459
460        {
461            let engine = self
462                .inner
463                .engine
464                .read()
465                .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
466            let engine_matches = engine
467                .as_ref()
468                .is_some_and(|engine| engine.state.engine_id == response_usm.engine_id);
469            if !engine_matches {
470                tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr() }, "USM authoritative engine does not select the cached localized keys");
471                return Err(Error::Auth {
472                    target: self.peer_addr(),
473                }
474                .boxed());
475            }
476        }
477
478        if !received_level.requires_auth() {
479            if security.security_level().requires_auth()
480                && !self.inner.config.allow_unauthenticated_v3_time_correction
481            {
482                tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr() }, "unauthenticated reply on authenticated session");
483                return Err(Error::Auth {
484                    target: self.peer_addr(),
485                }
486                .boxed());
487            }
488            return Ok(());
489        }
490
491        tracing::trace!(target: "async_snmp::client", "verifying HMAC authentication on response");
492
493        let engine = self
494            .inner
495            .engine
496            .read()
497            .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
498        let derived = &engine
499            .as_ref()
500            .ok_or_else(|| Error::Config("engine not discovered".into()).boxed())?
501            .derived_keys;
502        let auth_key = derived.auth_key.as_ref().ok_or_else(|| {
503            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %AuthErrorKind::NoAuthKey }, "authentication failed");
504            Error::Auth {
505                target: self.peer_addr(),
506            }
507            .boxed()
508        })?;
509
510        if received_level.requires_priv() && derived.priv_key.is_none() {
511            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %CryptoErrorKind::NoPrivKey }, "received security level is unsupported");
512            return Err(Error::Auth {
513                target: self.peer_addr(),
514            }
515            .boxed());
516        }
517
518        let (offset, len) = UsmSecurityParams::find_auth_params_offset(response_data).ok_or_else(
519            || {
520                tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %AuthErrorKind::AuthParamsNotFound }, "authentication failed");
521                Error::Auth {
522                    target: self.peer_addr(),
523                }
524                .boxed()
525            },
526        )?;
527
528        if !verify_message(auth_key, response_data, offset, len)
529            .map_err(|e| Error::Config(e.to_string().into()).boxed())?
530        {
531            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %AuthErrorKind::HmacMismatch }, "authentication failed");
532            return Err(Error::Auth {
533                target: self.peer_addr(),
534            }
535            .boxed());
536        }
537
538        tracing::trace!(target: "async_snmp::client", { auth_params_offset = offset, auth_params_len = len }, "HMAC verification successful");
539        Ok(())
540    }
541
542    /// Decrypt an encrypted scoped PDU and parse it.
543    fn decrypt_scoped_pdu(
544        &self,
545        ciphertext: &Bytes,
546        usm_params: &UsmSecurityParams,
547    ) -> Result<ScopedPdu> {
548        tracing::trace!(target: "async_snmp::client", { ciphertext_len = ciphertext.len() }, "decrypting response");
549
550        let engine = self
551            .inner
552            .engine
553            .read()
554            .map_err(|_| Error::Config("engine lock poisoned".into()).boxed())?;
555        let priv_key = engine
556            .as_ref()
557            .and_then(|engine| engine.derived_keys.priv_key.as_ref())
558            .ok_or_else(|| {
559                tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %CryptoErrorKind::NoPrivKey }, "decryption failed");
560                Error::Auth {
561                    target: self.peer_addr(),
562                }
563                .boxed()
564            })?;
565
566        let plaintext = priv_key
567            .decrypt(
568                ciphertext,
569                usm_params.engine_boots,
570                usm_params.engine_time,
571                &usm_params.priv_params,
572            )
573            .map_err(|e| {
574                tracing::warn!(target: "async_snmp::crypto", { peer = %self.peer_addr(), error = %e }, "decryption failed");
575                Error::Auth {
576                    target: self.peer_addr(),
577                }
578                .boxed()
579            })?;
580
581        tracing::trace!(target: "async_snmp::client", { plaintext_len = plaintext.len() }, "decrypted response");
582
583        let mut decoder = Decoder::with_target(plaintext, self.peer_addr());
584        ScopedPdu::decode(&mut decoder)
585    }
586
587    /// Send a V3 request and handle the response.
588    #[instrument(
589        level = "debug",
590        skip(self, pdu),
591        fields(
592            snmp.target = %self.peer_addr(),
593            snmp.request_id = pdu.request_id,
594            snmp.security_level = ?self.inner.config.v3_security.as_ref().map(crate::UsmConfig::security_level),
595            snmp.attempt = tracing::field::Empty,
596            snmp.protocol_correction = tracing::field::Empty,
597            snmp.elapsed_ms = tracing::field::Empty,
598        )
599    )]
600    pub(super) async fn send_v3_and_recv(&self, pdu: Pdu) -> Result<Pdu> {
601        let start = Instant::now();
602
603        // Ensure engine is discovered first
604        self.ensure_engine_discovered().await?;
605
606        let security = self
607            .inner
608            .config
609            .v3_security
610            .as_ref()
611            .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
612        let security_level = security.security_level();
613
614        let max_timeout_retries = if self.inner.transport.is_reliable() {
615            0
616        } else {
617            self.inner.config.retry.max_attempts
618        };
619        let mut timeout_retries = 0;
620        let mut correction_used = false;
621        let mut packet_local_engine_time = None;
622        let mut pdu = pdu;
623        // msgIDs transmitted for the current exchange. A response correlating to
624        // any of them is acceptable; corrections reset the window because the
625        // corrected message is a new exchange.
626        let mut msg_id_window: Vec<i32> = Vec::new();
627
628        loop {
629            Span::current().record("snmp.attempt", timeout_retries);
630            Span::current().record("snmp.protocol_correction", correction_used);
631
632            // RFC 3412 Section 6.2: use fresh msgID for every transmission. Prior
633            // attempts' msgIDs stay acceptable via the window below. A
634            // compatibility tuple is consumed by exactly one packet and is never
635            // available to a timeout retransmission.
636            let msg_id = self.next_request_id();
637            let engine_time_override = packet_local_engine_time.take();
638            let request = self.build_v3_message(&pdu, msg_id, engine_time_override.as_ref())?;
639
640            tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?pdu.pdu_type, snmp.varbind_count = pdu.varbinds.len(), snmp.msg_id = msg_id }, "sending V3 {} request", pdu.pdu_type);
641            tracing::trace!(target: "async_snmp::client", { snmp.bytes = request.data.len() }, "sending V3 request");
642
643            // Register (or re-register) with fresh deadline before sending
644            self.inner
645                .transport
646                .register_request(msg_id, self.inner.config.timeout);
647            for &prior in &msg_id_window {
648                self.inner.transport.register_request_alias(
649                    prior,
650                    msg_id,
651                    self.inner.config.timeout,
652                );
653            }
654            msg_id_window.push(msg_id);
655
656            // Send request and wait for response as a single unit so reliable
657            // transports own their stream lock for the whole exchange.
658            match self.inner.transport.request(&request.data, msg_id).await {
659                Ok((response_data, _source)) => {
660                    tracing::trace!(target: "async_snmp::client", { snmp.bytes = response_data.len() }, "received V3 response");
661
662                    // RFC 3412 Section 7.2: decode only the envelope and
663                    // derive the security level from the received flags.
664                    // Invalid flag combinations (privacy without
665                    // authentication) are rejected inside the decode, before
666                    // any authentication work.
667                    let raw = RawV3Message::decode(response_data.clone())?;
668                    let received_level = raw.security_level();
669                    let response_usm = UsmSecurityParams::decode(raw.security_params.clone())?;
670
671                    // RFC 3414 Steps 3-6: bind the received engine ID and
672                    // security name to the cached localized keys, validate
673                    // support for the received level, then authenticate.
674                    self.verify_response_security(&response_data, &response_usm, received_level)?;
675
676                    // RFC 3414 Section 3.2 Step 7b: for every HMAC-verified
677                    // message, advance the local notion of the engine's
678                    // boots/time and check timeliness before decryption and
679                    // PDU parsing. Only state for the message's claimed
680                    // engine is touched; unauthenticated messages never
681                    // mutate the notion.
682                    let mut deferred_authenticated_update = false;
683                    if received_level.requires_auth() {
684                        let timely = if engine_time_override.is_some() {
685                            // A response to the packet-local compatibility
686                            // correction is provisional until it is proven to
687                            // be the fully matched Response for this request.
688                            // Evaluate Step 7(b) on a clone so malformed,
689                            // uncorrelated, or Report replies cannot mutate live
690                            // or shared trusted state.
691                            let local_state = {
692                                let engine = self.inner.engine.read().map_err(|_| {
693                                    Error::Config("engine lock poisoned".into()).boxed()
694                                })?;
695                                engine
696                                    .as_ref()
697                                    .ok_or_else(|| {
698                                        Error::Config("engine not discovered".into()).boxed()
699                                    })?
700                                    .state
701                                    .clone()
702                            };
703                            let timely = self
704                                .inner
705                                .engine_cache
706                                .as_deref()
707                                .and_then(|cache| {
708                                    cache.timeliness_candidate(
709                                        &self.peer_addr(),
710                                        &local_state,
711                                        &response_usm.engine_id,
712                                        response_usm.engine_boots,
713                                        response_usm.engine_time,
714                                    )
715                                })
716                                .map_or_else(
717                                    || {
718                                        local_state.clone().check_and_update_timeliness(
719                                            response_usm.engine_boots,
720                                            response_usm.engine_time,
721                                        )
722                                    },
723                                    |(timely, _candidate)| timely,
724                                );
725                            deferred_authenticated_update = true;
726                            timely
727                        } else {
728                            let mut engine = self.inner.engine.write().map_err(|_| {
729                                Error::Config("engine lock poisoned".into()).boxed()
730                            })?;
731                            let engine = engine.as_mut().ok_or_else(|| {
732                                Error::Config("engine not discovered".into()).boxed()
733                            })?;
734                            check_and_update_engine_timeliness(
735                                &mut engine.state,
736                                self.inner.engine_cache.as_deref(),
737                                self.peer_addr(),
738                                &response_usm.engine_id,
739                                response_usm.engine_boots,
740                                response_usm.engine_time,
741                            )
742                        };
743
744                        if !timely {
745                            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), msg_boots = response_usm.engine_boots, msg_time = response_usm.engine_time }, "message outside time window");
746                            return Err(Error::Auth {
747                                target: self.peer_addr(),
748                            }
749                            .boxed());
750                        }
751                    }
752
753                    #[cfg(test)]
754                    if deferred_authenticated_update {
755                        let hook = self
756                            .inner
757                            .deferred_authenticated_update_hook
758                            .read()
759                            .expect("deferred update hook lock poisoned")
760                            .clone();
761                        if let Some(hook) = hook {
762                            hook();
763                        }
764                    }
765
766                    // Security processing is complete: decrypt (per the
767                    // received level) and parse the scoped PDU.
768                    let scoped_pdu = match &raw.msg_data {
769                        RawMsgData::Plaintext(bytes) => {
770                            let mut decoder = Decoder::with_target(bytes.clone(), self.peer_addr());
771                            ScopedPdu::decode(&mut decoder)?
772                        }
773                        RawMsgData::Encrypted(ciphertext) => {
774                            self.decrypt_scoped_pdu(ciphertext, &response_usm)?
775                        }
776                    };
777
778                    // RFC 3412 Section 7.2: Security Model processing above
779                    // precedes Message Processing Model correlation. Bind both
780                    // ordinary Responses and Reports to this exact attempt.
781                    // A mismatch terminates the exchange because Transport's
782                    // request API does not universally support receiving a
783                    // second packet (notably custom transports).
784                    if !msg_id_window.contains(&raw.global_data.msg_id) {
785                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), expected_msg_ids = ?msg_id_window, actual_msg_id = raw.global_data.msg_id }, "msgID mismatch in response");
786                        return Err(Error::MalformedResponse {
787                            target: self.peer_addr(),
788                        }
789                        .boxed());
790                    }
791
792                    // Report action begins only after USM processing and exact
793                    // outer-msgID correlation. Strict classification prevents
794                    // an OID hidden in a malformed/multi-status Report from
795                    // triggering a corrected send.
796                    if scoped_pdu.pdu.pdu_type == PduType::Report {
797                        let status = classify_report(&scoped_pdu.pdu).map_err(|_| {
798                            Error::MalformedResponse {
799                                target: self.peer_addr(),
800                            }
801                            .boxed()
802                        })?;
803
804                        if matches!(status, ReportStatus::NotInTimeWindow { .. })
805                            && received_level.requires_auth()
806                            && !correction_used
807                        {
808                            // RFC 3414 Step 7(b) above has already established
809                            // the authenticated tuple. One protocol correction
810                            // is allowed independently of timeout retries and
811                            // transport reliability. A corrected message is a
812                            // new request and receives fresh message and PDU IDs.
813                            correction_used = true;
814                            pdu.request_id = self.next_request_id();
815                            msg_id_window.clear();
816                            Span::current().record("snmp.protocol_correction", true);
817                            tracing::debug!(target: "async_snmp::client", { snmp.report_status = %status }, "sending SNMPv3 protocol correction");
818                            continue;
819                        }
820
821                        if matches!(status, ReportStatus::NotInTimeWindow { .. })
822                            && received_level == SecurityLevel::NoAuthNoPriv
823                            && security_level.requires_auth()
824                            && self.inner.config.allow_unauthenticated_v3_time_correction
825                            && response_usm.auth_params.is_empty()
826                            && response_usm.priv_params.is_empty()
827                            && !correction_used
828                        {
829                            // Compatibility for devices that violate RFC 3414
830                            // Step 7(a) by returning this Report without HMAC.
831                            // Identity, username, source policy, msgID, and exact
832                            // Report shape have all been checked. The untrusted
833                            // tuple is consumed by one authenticated packet and
834                            // is never installed into live or cache state.
835                            correction_used = true;
836                            packet_local_engine_time = Some(PacketLocalEngineTime {
837                                engine_id: response_usm.engine_id.clone(),
838                                boots: response_usm.engine_boots,
839                                time: response_usm.engine_time,
840                            });
841                            pdu.request_id = self.next_request_id();
842                            msg_id_window.clear();
843                            Span::current().record("snmp.protocol_correction", true);
844                            tracing::debug!(target: "async_snmp::client", { snmp.report_status = %status }, "sending packet-local SNMPv3 compatibility correction");
845                            continue;
846                        }
847
848                        // Credential failures, unknown statuses, disabled or
849                        // repeated unauthenticated time Reports, and repeated
850                        // authenticated time Reports are typed terminal
851                        // protocol outcomes, never timeouts.
852                        return Err(Error::Report {
853                            target: self.peer_addr(),
854                            status: Box::new(status),
855                        }
856                        .boxed());
857                    }
858
859                    // Validate security level matches what we sent (prevent downgrade attacks)
860                    if received_level != security_level {
861                        tracing::warn!(target: "async_snmp::client", {
862                            peer = %self.peer_addr(),
863                            expected = ?security_level,
864                            actual = ?received_level
865                        }, "security level mismatch in response");
866                        return Err(Error::MalformedResponse {
867                            target: self.peer_addr(),
868                        }
869                        .boxed());
870                    }
871
872                    // Validate engine ID matches our cached engine state
873                    {
874                        let engine =
875                            self.inner.engine.read().map_err(|_| {
876                                Error::Config("engine lock poisoned".into()).boxed()
877                            })?;
878                        if let Some(ref engine) = *engine
879                            && response_usm.engine_id != engine.state.engine_id
880                        {
881                            tracing::warn!(target: "async_snmp::client", {
882                                peer = %self.peer_addr()
883                            }, "engine ID mismatch in response");
884                            return Err(Error::MalformedResponse {
885                                target: self.peer_addr(),
886                            }
887                            .boxed());
888                        }
889                    }
890
891                    // Validate username matches what we sent
892                    if response_usm.username != security.username() {
893                        tracing::warn!(target: "async_snmp::client", {
894                            peer = %self.peer_addr()
895                        }, "username mismatch in response");
896                        return Err(Error::MalformedResponse {
897                            target: self.peer_addr(),
898                        }
899                        .boxed());
900                    }
901
902                    // RFC 3412 Section 7.2: an ordinary Response must match
903                    // both scoped-context values cached for the request.
904                    if scoped_pdu.context_engine_id != request.context_engine_id
905                        || scoped_pdu.context_name != request.context_name
906                    {
907                        tracing::warn!(target: "async_snmp::client", {
908                            peer = %self.peer_addr()
909                        }, "scoped context mismatch in response");
910                        return Err(Error::MalformedResponse {
911                            target: self.peer_addr(),
912                        }
913                        .boxed());
914                    }
915
916                    let response_pdu = scoped_pdu.pdu;
917
918                    // RFC 3416 Section 4.2: only a Response-PDU may answer a
919                    // request; reject echoed request-type PDUs (Report PDUs
920                    // were classified above)
921                    if response_pdu.pdu_type != PduType::Response {
922                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), pdu_type = ?response_pdu.pdu_type }, "non-Response PDU in response");
923                        return Err(Error::MalformedResponse {
924                            target: self.peer_addr(),
925                        }
926                        .boxed());
927                    }
928
929                    // Validate request ID
930                    if response_pdu.request_id != pdu.request_id {
931                        tracing::warn!(target: "async_snmp::client", { expected_request_id = pdu.request_id, actual_request_id = response_pdu.request_id, peer = %self.peer_addr() }, "request ID mismatch in response");
932                        return Err(Error::MalformedResponse {
933                            target: self.peer_addr(),
934                        }
935                        .boxed());
936                    }
937
938                    // The response to a packet-local compatibility correction
939                    // is now authenticated, correlated, and fully matched.
940                    // Revalidate against the current coherent live/cache state
941                    // before publishing: another request may have advanced the
942                    // time window while this response was being processed.
943                    if deferred_authenticated_update {
944                        let timely = {
945                            let mut engine = self.inner.engine.write().map_err(|_| {
946                                Error::Config("engine lock poisoned".into()).boxed()
947                            })?;
948                            let engine = engine.as_mut().ok_or_else(|| {
949                                Error::Config("engine not discovered".into()).boxed()
950                            })?;
951                            if engine.state.engine_id != response_usm.engine_id {
952                                return Err(Error::MalformedResponse {
953                                    target: self.peer_addr(),
954                                }
955                                .boxed());
956                            }
957                            check_and_update_engine_timeliness(
958                                &mut engine.state,
959                                self.inner.engine_cache.as_deref(),
960                                self.peer_addr(),
961                                &response_usm.engine_id,
962                                response_usm.engine_boots,
963                                response_usm.engine_time,
964                            )
965                        };
966                        if !timely {
967                            return Err(Error::Auth {
968                                target: self.peer_addr(),
969                            }
970                            .boxed());
971                        }
972                    }
973
974                    tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?response_pdu.pdu_type, snmp.varbind_count = response_pdu.varbinds.len(), snmp.error_status = response_pdu.error_status, snmp.error_index = response_pdu.error_index }, "received V3 {} response", response_pdu.pdu_type);
975
976                    // Check for SNMP error
977                    if let Some(err) = super::pdu_to_snmp_error(&response_pdu, self.peer_addr()) {
978                        Span::current()
979                            .record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
980                        return Err(err);
981                    }
982
983                    Span::current().record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
984                    return Ok(response_pdu);
985                }
986                Err(e) if matches!(*e, Error::Timeout { .. }) => {
987                    // A spoofable compatibility tuple is authorized for one
988                    // authenticated packet only, not a retransmission.
989                    if engine_time_override.is_some() || timeout_retries >= max_timeout_retries {
990                        break;
991                    }
992
993                    let delay = self.inner.config.retry.compute_delay(timeout_retries);
994                    timeout_retries += 1;
995                    // Retain the PDU request-id across timeout retransmissions,
996                    // matching deployed net-snmp and SNMP4J behavior. RFC 3414
997                    // Section 11.1 literally requires distinct request-ids in all
998                    // Request PDUs sent during a TimeWindow, so this is a deliberate
999                    // interoperability deviation. A fresh msgID still distinguishes
1000                    // each transmission, and any current-window msgID remains valid.
1001                    tracing::debug!(target: "async_snmp::client", { timeout_retries, delay_ms = delay.as_millis() as u64 }, "retransmitting V3 request after timeout");
1002                    if !delay.is_zero() {
1003                        tokio::time::sleep(delay).await;
1004                    }
1005                }
1006                Err(e) => {
1007                    Span::current().record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
1008                    return Err(e);
1009                }
1010            }
1011        }
1012
1013        // Only transport timeouts reach loop exhaustion. Reports always take a
1014        // protocol transition or return their typed terminal outcome.
1015        let elapsed = start.elapsed();
1016        Span::current().record("snmp.elapsed_ms", elapsed.as_millis() as u64);
1017        tracing::debug!(target: "async_snmp::client", { request_id = pdu.request_id, peer = %self.peer_addr(), ?elapsed, retries = timeout_retries }, "request timed out");
1018        Err(Error::Timeout {
1019            target: self.peer_addr(),
1020            elapsed,
1021            retries: timeout_retries,
1022        }
1023        .boxed())
1024    }
1025
1026    /// Ensure keys are derived against the local engine ID for V3 trap sending.
1027    pub(super) fn ensure_local_keys_derived(&self) -> Result<()> {
1028        // Fast path: already derived.
1029        {
1030            let keys =
1031                self.inner.local_derived_keys.read().map_err(|_| {
1032                    Error::Config("local_derived_keys lock poisoned".into()).boxed()
1033                })?;
1034            if keys.is_some() {
1035                return Ok(());
1036            }
1037        }
1038
1039        let local_engine = self.local_engine_for_trap()?;
1040
1041        let security = self
1042            .inner
1043            .config
1044            .v3_security
1045            .as_ref()
1046            .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
1047
1048        let keys = security
1049            .derive_keys(local_engine.engine_id())
1050            .map_err(|e| Error::Config(e.to_string().into()).boxed())?;
1051
1052        let mut derived = self
1053            .inner
1054            .local_derived_keys
1055            .write()
1056            .map_err(|_| Error::Config("local_derived_keys lock poisoned".into()).boxed())?;
1057        *derived = Some(keys);
1058
1059        Ok(())
1060    }
1061
1062    fn local_engine_for_trap(&self) -> Result<&crate::v3::AuthoritativeEngine> {
1063        self.inner
1064            .config
1065            .local_authoritative_engine
1066            .as_ref()
1067            .ok_or_else(|| {
1068                Error::Config(
1069                    "local authoritative engine state required for V3 trap sending".into(),
1070                )
1071                .boxed()
1072            })
1073    }
1074
1075    /// Build and encode a V3 trap message using local engine ID.
1076    ///
1077    /// Per RFC 3412 Section 6.4, the sender is the authoritative engine for
1078    /// trap PDUs. Uses the persisted local engine state and sets
1079    /// reportable=false (no Report PDU expected for traps).
1080    pub(super) fn build_v3_trap_message(&self, pdu: &Pdu, msg_id: i32) -> Result<Vec<u8>> {
1081        let security = self
1082            .inner
1083            .config
1084            .v3_security
1085            .as_ref()
1086            .ok_or_else(|| Error::Config("V3 security not configured".into()).boxed())?;
1087
1088        let local_engine = self.local_engine_for_trap()?;
1089
1090        let derived = self
1091            .inner
1092            .local_derived_keys
1093            .read()
1094            .map_err(|_| Error::Config("local_derived_keys lock poisoned".into()).boxed())?;
1095
1096        let (engine_boots, engine_time) = local_engine.current_boots_time()?;
1097
1098        crate::v3::encode::encode_v3_message(
1099            pdu,
1100            msg_id,
1101            local_engine.engine_id(),
1102            engine_boots,
1103            engine_time,
1104            security,
1105            derived.as_ref(),
1106            &self.inner.salt_counter,
1107            false, // reportable=false for traps
1108            crate::v3::DEFAULT_MSG_MAX_SIZE,
1109        )
1110    }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use crate::UsmConfig;
1117    use crate::client::ClientConfig;
1118    use crate::message::V3MessageData;
1119    use crate::oid;
1120    use crate::transport::Transport;
1121    use bytes::Bytes;
1122    use std::future::ready;
1123    use std::net::{Ipv4Addr, SocketAddr};
1124    use std::sync::Arc;
1125    use std::sync::atomic::{AtomicU32, Ordering};
1126    use std::time::Duration;
1127
1128    #[derive(Clone)]
1129    struct TestTransport {
1130        peer: SocketAddr,
1131    }
1132
1133    impl TestTransport {
1134        fn new() -> Self {
1135            Self {
1136                peer: SocketAddr::from((Ipv4Addr::LOCALHOST, 161)),
1137            }
1138        }
1139    }
1140
1141    impl Transport for TestTransport {
1142        fn send(&self, _data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1143            ready(Ok(()))
1144        }
1145
1146        fn recv(
1147            &self,
1148            _request_id: i32,
1149        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1150            ready(Err(Error::Config(
1151                "test transport does not receive data".into(),
1152            )
1153            .boxed()))
1154        }
1155
1156        fn peer_addr(&self) -> SocketAddr {
1157            self.peer
1158        }
1159
1160        fn local_addr(&self) -> SocketAddr {
1161            SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))
1162        }
1163
1164        fn is_reliable(&self) -> bool {
1165            false
1166        }
1167
1168        fn register_request(&self, _request_id: i32, _timeout: Duration) {}
1169    }
1170
1171    #[tokio::test]
1172    async fn direct_config_requires_authoritative_state_before_sending_v3_trap() {
1173        let config = ClientConfig {
1174            version: crate::Version::V3,
1175            v3_security: Some(UsmConfig::new("trapuser")),
1176            ..ClientConfig::default()
1177        };
1178        let client = Client::new(TestTransport::new(), config);
1179
1180        let err = client
1181            .send_trap(&oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 1), 0, vec![])
1182            .await
1183            .unwrap_err();
1184        assert!(matches!(*err, Error::Config(_)));
1185    }
1186
1187    #[test]
1188    fn test_rejected_message_does_not_reinsert_missing_cache_entry() {
1189        let cache = EngineCache::new();
1190        let target = SocketAddr::from((Ipv4Addr::LOCALHOST, 161));
1191        let engine_id = Bytes::from_static(b"engine");
1192        let mut state = EngineState::new(engine_id.clone(), 5, 1000);
1193
1194        let timely = check_and_update_engine_timeliness(
1195            &mut state,
1196            Some(&cache),
1197            target,
1198            &engine_id,
1199            4,
1200            5000,
1201        );
1202
1203        assert!(!timely);
1204        assert!(cache.get(&target).is_none());
1205        assert!(cache.is_empty());
1206    }
1207
1208    #[test]
1209    fn test_deferred_time_revalidation_rejects_concurrently_stale_response() {
1210        let cache = EngineCache::new();
1211        let target = SocketAddr::from((Ipv4Addr::LOCALHOST, 161));
1212        let engine_id = Bytes::from_static(b"engine");
1213        let mut state = EngineState::new(engine_id.clone(), 1, 1000);
1214        cache.insert(target, state.clone());
1215
1216        let (initially_timely, _) = cache
1217            .timeliness_candidate(&target, &state, &engine_id, 1, 1100)
1218            .expect("cached identity");
1219        assert!(initially_timely);
1220
1221        assert!(check_and_update_engine_timeliness(
1222            &mut state,
1223            Some(&cache),
1224            target,
1225            &engine_id,
1226            1,
1227            1400,
1228        ));
1229        assert!(!check_and_update_engine_timeliness(
1230            &mut state,
1231            Some(&cache),
1232            target,
1233            &engine_id,
1234            1,
1235            1100,
1236        ));
1237    }
1238
1239    #[test]
1240    fn test_build_v3_message_uses_configured_context_name() {
1241        let transport = TestTransport::new();
1242        let config = ClientConfig {
1243            version: crate::version::Version::V3,
1244            v3_security: Some(UsmConfig::new("user").context_name("ctx")),
1245            ..ClientConfig::default()
1246        };
1247        let client = Client::new(transport, config);
1248
1249        {
1250            let security = client.inner.config.v3_security.as_ref().unwrap();
1251            let state = EngineState::new(Bytes::from_static(b"engine"), 1, 42);
1252            let derived_keys = security.derive_keys(&state.engine_id).unwrap();
1253            *client.inner.engine.write().expect("engine lock poisoned") = Some(ClientEngine {
1254                state,
1255                derived_keys,
1256            });
1257        }
1258
1259        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)]);
1260
1261        let encoded = client
1262            .build_v3_message(&pdu, 456, None)
1263            .expect("v3 message should encode");
1264        let decoded =
1265            V3Message::decode(Bytes::from(encoded.data)).expect("v3 message should decode");
1266        let scoped = match decoded.data {
1267            V3MessageData::Plaintext(scoped) => scoped,
1268            V3MessageData::Encrypted(_) => panic!("expected plaintext scoped PDU"),
1269        };
1270
1271        assert_eq!(scoped.context_name.as_ref(), b"ctx");
1272    }
1273
1274    #[test]
1275    fn test_packet_local_time_rejects_changed_engine_generation() {
1276        let transport = TestTransport::new();
1277        let config = ClientConfig {
1278            version: crate::version::Version::V3,
1279            v3_security: Some(UsmConfig::new("user")),
1280            ..ClientConfig::default()
1281        };
1282        let client = Client::new(transport, config);
1283
1284        {
1285            let security = client.inner.config.v3_security.as_ref().unwrap();
1286            let state = EngineState::new(Bytes::from_static(b"engine-a"), 1, 42);
1287            let derived_keys = security.derive_keys(&state.engine_id).unwrap();
1288            *client.inner.engine.write().expect("engine lock poisoned") = Some(ClientEngine {
1289                state,
1290                derived_keys,
1291            });
1292        }
1293
1294        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)]);
1295        let packet_time = PacketLocalEngineTime {
1296            engine_id: Bytes::from_static(b"engine-b"),
1297            boots: 9,
1298            time: 99,
1299        };
1300        let err = client
1301            .build_v3_message(&pdu, 456, Some(&packet_time))
1302            .err()
1303            .expect("changed engine generation must fail");
1304
1305        assert!(matches!(*err, Error::MalformedResponse { .. }));
1306    }
1307
1308    /// Transport that times out on the first recv call, then returns a valid
1309    /// discovery response on subsequent calls.
1310    #[derive(Clone)]
1311    struct RetryTestTransport {
1312        peer: SocketAddr,
1313        recv_count: Arc<AtomicU32>,
1314        engine_id: Bytes,
1315    }
1316
1317    impl RetryTestTransport {
1318        fn new(engine_id: Bytes) -> Self {
1319            Self {
1320                peer: SocketAddr::from((Ipv4Addr::LOCALHOST, 161)),
1321                recv_count: Arc::new(AtomicU32::new(0)),
1322                engine_id,
1323            }
1324        }
1325    }
1326
1327    impl Transport for RetryTestTransport {
1328        fn send(&self, _data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1329            ready(Ok(()))
1330        }
1331
1332        fn recv(
1333            &self,
1334            request_id: i32,
1335        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1336            let count = self.recv_count.fetch_add(1, Ordering::Relaxed);
1337            let peer = self.peer;
1338            let engine_id = self.engine_id.clone();
1339            async move {
1340                if count == 0 {
1341                    // First call: simulate a timeout
1342                    Err(Error::Timeout {
1343                        target: peer,
1344                        elapsed: Duration::from_secs(5),
1345                        retries: 0,
1346                    }
1347                    .boxed())
1348                } else {
1349                    Ok((build_discovery_response(&engine_id, request_id), peer))
1350                }
1351            }
1352        }
1353
1354        fn peer_addr(&self) -> SocketAddr {
1355            self.peer
1356        }
1357
1358        fn local_addr(&self) -> SocketAddr {
1359            SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))
1360        }
1361
1362        fn is_reliable(&self) -> bool {
1363            false
1364        }
1365
1366        fn register_request(&self, _request_id: i32, _timeout: Duration) {}
1367    }
1368
1369    /// Build a minimal valid discovery response with the given engine ID.
1370    fn build_discovery_response(engine_id: &[u8], msg_id: i32) -> Bytes {
1371        use crate::message::{MsgFlags, MsgGlobalData, ScopedPdu, V3Message};
1372        use crate::pdu::{Pdu, PduType};
1373        use crate::v3::UsmSecurityParams;
1374        use crate::value::Value;
1375        use crate::varbind::VarBind;
1376
1377        let report_pdu = Pdu {
1378            pdu_type: PduType::Report,
1379            request_id: 1,
1380            error_status: 0,
1381            error_index: 0,
1382            varbinds: vec![VarBind::new(
1383                crate::oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 4, 0),
1384                Value::Counter32(0),
1385            )],
1386        };
1387
1388        let global = MsgGlobalData::new(
1389            msg_id,
1390            65507,
1391            MsgFlags::new(crate::message::SecurityLevel::NoAuthNoPriv, false),
1392        );
1393        let usm = UsmSecurityParams::new(Bytes::copy_from_slice(engine_id), 1, 100, Bytes::new());
1394        let scoped = ScopedPdu::new(Bytes::copy_from_slice(engine_id), Bytes::new(), report_pdu);
1395
1396        V3Message::new(global, usm.encode(), scoped).encode()
1397    }
1398
1399    #[tokio::test]
1400    async fn test_discovery_retries_on_timeout() {
1401        let engine_id = b"test-engine";
1402        let transport = RetryTestTransport::new(Bytes::copy_from_slice(engine_id));
1403        let recv_count = transport.recv_count.clone();
1404
1405        let config = ClientConfig {
1406            version: crate::version::Version::V3,
1407            v3_security: Some(UsmConfig::new("user")),
1408            retry: crate::client::Retry::fixed(1, Duration::ZERO),
1409            ..ClientConfig::default()
1410        };
1411        let client = Client::new(transport, config);
1412
1413        client
1414            .ensure_engine_discovered()
1415            .await
1416            .expect("discovery should succeed after retry");
1417
1418        // recv was called twice: once for the timeout, once for the success
1419        assert_eq!(recv_count.load(Ordering::Relaxed), 2);
1420
1421        // Engine identity should be set without trusting discovery time.
1422        let engine = client.inner.engine.read().expect("engine lock poisoned");
1423        assert!(engine.is_some());
1424        let state = &engine.as_ref().unwrap().state;
1425        assert_eq!(state.engine_id.as_ref(), engine_id);
1426        assert!(state.trusted_time().is_none());
1427    }
1428
1429    #[tokio::test]
1430    async fn test_discovery_fails_when_all_retries_timeout() {
1431        // Transport that always times out
1432        #[derive(Clone)]
1433        struct AlwaysTimeoutTransport {
1434            peer: SocketAddr,
1435        }
1436        impl Transport for AlwaysTimeoutTransport {
1437            fn send(&self, _data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1438                ready(Ok(()))
1439            }
1440            fn recv(
1441                &self,
1442                _request_id: i32,
1443            ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1444                let peer = self.peer;
1445                async move {
1446                    Err(Error::Timeout {
1447                        target: peer,
1448                        elapsed: Duration::from_secs(5),
1449                        retries: 0,
1450                    }
1451                    .boxed())
1452                }
1453            }
1454            fn peer_addr(&self) -> SocketAddr {
1455                self.peer
1456            }
1457            fn local_addr(&self) -> SocketAddr {
1458                SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))
1459            }
1460            fn is_reliable(&self) -> bool {
1461                false
1462            }
1463            fn register_request(&self, _request_id: i32, _timeout: Duration) {}
1464        }
1465
1466        let transport = AlwaysTimeoutTransport {
1467            peer: SocketAddr::from((Ipv4Addr::LOCALHOST, 161)),
1468        };
1469        let config = ClientConfig {
1470            version: crate::version::Version::V3,
1471            v3_security: Some(UsmConfig::new("user")),
1472            retry: crate::client::Retry::fixed(2, Duration::ZERO),
1473            ..ClientConfig::default()
1474        };
1475        let client = Client::new(transport, config);
1476
1477        let result = client.ensure_engine_discovered().await;
1478        assert!(
1479            matches!(*result.unwrap_err(), Error::Timeout { .. }),
1480            "should return Timeout after all retries exhausted"
1481        );
1482    }
1483}
1484
1485#[cfg(test)]
1486mod response_validation_tests {
1487    use super::*;
1488    use crate::UsmConfig;
1489    use crate::client::ClientConfig;
1490    use crate::message::{MsgFlags, MsgGlobalData, ScopedPdu, SecurityLevel, V3MessageData};
1491    use crate::oid;
1492    use crate::v3::auth::authenticate_message;
1493    use crate::v3::{AuthProtocol, EngineState, LocalizedKey};
1494    use bytes::Bytes;
1495    use std::future::ready;
1496    use std::net::{Ipv4Addr, SocketAddr};
1497    use std::sync::Arc;
1498    use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
1499    use std::time::Duration;
1500
1501    /// Transport that answers every request with one canned response.
1502    #[derive(Clone)]
1503    struct CannedTransport {
1504        peer: SocketAddr,
1505        response: Bytes,
1506        max_size: u32,
1507    }
1508
1509    impl CannedTransport {
1510        fn new(response: Bytes) -> Self {
1511            Self {
1512                peer: SocketAddr::from((Ipv4Addr::LOCALHOST, 161)),
1513                response,
1514                max_size: crate::v3::DEFAULT_MSG_MAX_SIZE,
1515            }
1516        }
1517    }
1518
1519    impl Transport for CannedTransport {
1520        fn send(&self, _data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1521            ready(Ok(()))
1522        }
1523
1524        fn max_message_size(&self) -> u32 {
1525            self.max_size
1526        }
1527
1528        fn recv(
1529            &self,
1530            _request_id: i32,
1531        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1532            ready(Ok((self.response.clone(), self.peer)))
1533        }
1534
1535        fn peer_addr(&self) -> SocketAddr {
1536            self.peer
1537        }
1538
1539        fn local_addr(&self) -> SocketAddr {
1540            SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))
1541        }
1542
1543        fn is_reliable(&self) -> bool {
1544            true
1545        }
1546
1547        fn alloc_request_id(&self) -> i32 {
1548            // Canned responses in this module are intentionally built for ID 99.
1549            99
1550        }
1551
1552        fn register_request(&self, _request_id: i32, _timeout: Duration) {}
1553    }
1554
1555    #[derive(Clone)]
1556    struct DeferredUpdateTransport {
1557        peer: SocketAddr,
1558        response_number: Arc<AtomicU32>,
1559        next_request_id: Arc<AtomicI32>,
1560    }
1561
1562    impl DeferredUpdateTransport {
1563        fn new() -> Self {
1564            Self {
1565                peer: SocketAddr::from((Ipv4Addr::LOCALHOST, 161)),
1566                response_number: Arc::new(AtomicU32::new(0)),
1567                next_request_id: Arc::new(AtomicI32::new(100)),
1568            }
1569        }
1570    }
1571
1572    impl Transport for DeferredUpdateTransport {
1573        async fn send(&self, _data: &[u8]) -> Result<()> {
1574            Ok(())
1575        }
1576
1577        async fn recv(&self, _request_id: i32) -> Result<(Bytes, SocketAddr)> {
1578            Err(Error::Config("DeferredUpdateTransport uses request()".into()).boxed())
1579        }
1580
1581        async fn request(&self, data: &[u8], _request_id: i32) -> Result<(Bytes, SocketAddr)> {
1582            let response_number = self.response_number.fetch_add(1, Ordering::SeqCst);
1583            let response = build_deferred_update_response(data, response_number);
1584            Ok((response, self.peer))
1585        }
1586
1587        fn peer_addr(&self) -> SocketAddr {
1588            self.peer
1589        }
1590
1591        fn local_addr(&self) -> SocketAddr {
1592            SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))
1593        }
1594
1595        fn alloc_request_id(&self) -> i32 {
1596            self.next_request_id.fetch_add(1, Ordering::Relaxed)
1597        }
1598
1599        fn is_reliable(&self) -> bool {
1600            false
1601        }
1602    }
1603
1604    const ENGINE_ID: &[u8] = b"engine";
1605
1606    /// Build a v3 response message under ENGINE_ID for user "user". With
1607    /// `auth_password` the message is authNoPriv and HMAC-signed; otherwise
1608    /// noAuthNoPriv.
1609    fn build_response(
1610        pdu_type: PduType,
1611        request_id: i32,
1612        engine_boots: u32,
1613        engine_time: u32,
1614        auth_password: Option<&[u8]>,
1615    ) -> Bytes {
1616        let security_level = if auth_password.is_some() {
1617            SecurityLevel::AuthNoPriv
1618        } else {
1619            SecurityLevel::NoAuthNoPriv
1620        };
1621        let global = MsgGlobalData::new(99, 65507, MsgFlags::new(security_level, false));
1622        let mut usm = UsmSecurityParams::new(
1623            Bytes::from_static(ENGINE_ID),
1624            engine_boots,
1625            engine_time,
1626            Bytes::from_static(b"user"),
1627        );
1628        let auth_key = auth_password.map(|password| {
1629            LocalizedKey::from_password(AuthProtocol::Sha1, password, ENGINE_ID).unwrap()
1630        });
1631        if let Some(key) = &auth_key {
1632            usm = usm.with_auth_placeholder(key.mac_len());
1633        }
1634        let scoped = ScopedPdu::new(
1635            Bytes::from_static(ENGINE_ID),
1636            Bytes::new(),
1637            Pdu {
1638                pdu_type,
1639                request_id,
1640                error_status: 0,
1641                error_index: 0,
1642                varbinds: vec![],
1643            },
1644        );
1645        let msg = V3Message::new(global, usm.encode(), scoped);
1646        match auth_key {
1647            Some(key) => {
1648                let mut bytes = msg.encode().to_vec();
1649                let (offset, len) = UsmSecurityParams::find_auth_params_offset(&bytes).unwrap();
1650                authenticate_message(&key, &mut bytes, offset, len).unwrap();
1651                Bytes::from(bytes)
1652            }
1653            None => msg.encode(),
1654        }
1655    }
1656
1657    fn build_deferred_update_response(request_data: &[u8], response_number: u32) -> Bytes {
1658        let request = V3Message::decode(Bytes::copy_from_slice(request_data)).unwrap();
1659        let scoped_request = match request.data {
1660            V3MessageData::Plaintext(scoped) => scoped,
1661            V3MessageData::Encrypted(_) => panic!("expected authNoPriv request"),
1662        };
1663        let (level, engine_time, pdu) = match response_number {
1664            0 => (
1665                SecurityLevel::NoAuthNoPriv,
1666                1100,
1667                Pdu {
1668                    pdu_type: PduType::Report,
1669                    request_id: 0,
1670                    error_status: 0,
1671                    error_index: 0,
1672                    varbinds: vec![crate::VarBind::new(
1673                        crate::v3::report_oids::not_in_time_windows(),
1674                        crate::Value::Counter32(1),
1675                    )],
1676                },
1677            ),
1678            1 => (
1679                SecurityLevel::AuthNoPriv,
1680                1100,
1681                Pdu {
1682                    pdu_type: PduType::Response,
1683                    request_id: scoped_request.pdu.request_id,
1684                    error_status: 0,
1685                    error_index: 0,
1686                    varbinds: vec![],
1687                },
1688            ),
1689            2 => (
1690                SecurityLevel::AuthNoPriv,
1691                1400,
1692                Pdu {
1693                    pdu_type: PduType::Response,
1694                    request_id: scoped_request.pdu.request_id,
1695                    error_status: 0,
1696                    error_index: 0,
1697                    varbinds: vec![],
1698                },
1699            ),
1700            _ => panic!("unexpected deferred-update response {response_number}"),
1701        };
1702        let global = MsgGlobalData::new(
1703            request.global_data.msg_id,
1704            65507,
1705            MsgFlags::new(level, false),
1706        );
1707        let auth_key = (level == SecurityLevel::AuthNoPriv).then(|| {
1708            LocalizedKey::from_password(AuthProtocol::Sha1, b"authpass12345678", ENGINE_ID).unwrap()
1709        });
1710        let mut usm = UsmSecurityParams::new(
1711            Bytes::from_static(ENGINE_ID),
1712            1,
1713            engine_time,
1714            Bytes::from_static(b"user"),
1715        );
1716        if let Some(key) = &auth_key {
1717            usm = usm.with_auth_placeholder(key.mac_len());
1718        }
1719        let scoped = ScopedPdu::new(
1720            scoped_request.context_engine_id,
1721            scoped_request.context_name,
1722            pdu,
1723        );
1724        let mut response = V3Message::new(global, usm.encode(), scoped)
1725            .encode()
1726            .to_vec();
1727        if let Some(key) = auth_key {
1728            let (offset, len) = UsmSecurityParams::find_auth_params_offset(&response).unwrap();
1729            authenticate_message(&key, &mut response, offset, len).unwrap();
1730        }
1731        Bytes::from(response)
1732    }
1733
1734    /// Build a client over `response` with engine state (and derived keys,
1735    /// when authenticated) preset so no discovery round-trip runs.
1736    fn canned_client(
1737        response: Bytes,
1738        engine_boots: u32,
1739        engine_time: u32,
1740        security: UsmConfig,
1741    ) -> Client<CannedTransport> {
1742        let config = ClientConfig {
1743            version: crate::version::Version::V3,
1744            v3_security: Some(security.clone()),
1745            ..ClientConfig::default()
1746        };
1747        let client = Client::new(CannedTransport::new(response), config);
1748        {
1749            let state = EngineState::new(Bytes::from_static(ENGINE_ID), engine_boots, engine_time);
1750            let derived_keys = security.derive_keys(ENGINE_ID).unwrap();
1751            *client.inner.engine.write().unwrap() = Some(ClientEngine {
1752                state,
1753                derived_keys,
1754            });
1755        }
1756        client
1757    }
1758
1759    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1760    async fn v3_deferred_update_revalidates_after_concurrent_advancement() {
1761        let transport = DeferredUpdateTransport::new();
1762        let security = UsmConfig::new("user").auth(AuthProtocol::Sha1, "authpass12345678");
1763        let cache = Arc::new(EngineCache::new());
1764        let config = ClientConfig {
1765            version: crate::version::Version::V3,
1766            retry: crate::client::Retry::none(),
1767            v3_security: Some(security.clone()),
1768            allow_unauthenticated_v3_time_correction: true,
1769            ..ClientConfig::default()
1770        };
1771        let client = Client::with_engine_cache(transport, config, cache.clone());
1772        let state = EngineState::new(Bytes::from_static(ENGINE_ID), 1, 1000);
1773        let derived_keys = security.derive_keys(ENGINE_ID).unwrap();
1774        cache.insert(client.peer_addr(), state.clone());
1775        *client.inner.engine.write().unwrap() = Some(ClientEngine {
1776            state,
1777            derived_keys,
1778        });
1779
1780        let (candidate_checked_tx, candidate_checked_rx) = std::sync::mpsc::channel();
1781        let (advancement_complete_tx, advancement_complete_rx) = std::sync::mpsc::channel();
1782        let advancement_complete_rx = std::sync::Mutex::new(advancement_complete_rx);
1783        *client
1784            .inner
1785            .deferred_authenticated_update_hook
1786            .write()
1787            .unwrap() = Some(Arc::new(move || {
1788            candidate_checked_tx
1789                .send(())
1790                .expect("candidate-check waiter dropped");
1791            advancement_complete_rx
1792                .lock()
1793                .expect("advancement-complete lock poisoned")
1794                .recv_timeout(Duration::from_secs(5))
1795                .expect("concurrent advancement did not complete");
1796        }));
1797
1798        let stale_client = client.clone();
1799        let stale_request = tokio::spawn(async move {
1800            stale_client
1801                .send_v3_and_recv(Pdu::get_request(1, &[oid!(1, 3, 6, 1, 1)]))
1802                .await
1803        });
1804        tokio::task::spawn_blocking(move || {
1805            candidate_checked_rx.recv_timeout(Duration::from_secs(5))
1806        })
1807        .await
1808        .expect("candidate-check waiter panicked")
1809        .expect("provisional timeliness check did not complete");
1810
1811        client
1812            .send_v3_and_recv(Pdu::get_request(2, &[oid!(1, 3, 6, 1, 1)]))
1813            .await
1814            .expect("concurrent response should advance trusted time");
1815        advancement_complete_tx
1816            .send(())
1817            .expect("advancement-complete waiter dropped");
1818
1819        let err = stale_request.await.unwrap().unwrap_err();
1820        assert!(matches!(*err, Error::Auth { .. }));
1821        let engine = client.inner.engine.read().unwrap();
1822        let trusted = engine.as_ref().unwrap().state.trusted_time().unwrap();
1823        assert_eq!(trusted.latest_received_time(), 1400);
1824        assert_eq!(
1825            cache
1826                .get(&client.peer_addr())
1827                .unwrap()
1828                .trusted_time()
1829                .unwrap()
1830                .latest_received_time(),
1831            1400
1832        );
1833    }
1834
1835    /// RFC 3412 Section 6.3: an outgoing request advertises the manager's own
1836    /// receive capacity (the transport's `max_message_size`), not the remote
1837    /// engine's cached advertised limit.
1838    #[tokio::test]
1839    async fn v3_advertises_local_receive_capacity_not_remote() {
1840        let security = UsmConfig::new("user").auth(AuthProtocol::Sha1, "authpass12345678");
1841        let transport = CannedTransport {
1842            peer: SocketAddr::from((Ipv4Addr::LOCALHOST, 161)),
1843            response: Bytes::new(),
1844            max_size: 1400,
1845        };
1846        let config = ClientConfig {
1847            version: crate::version::Version::V3,
1848            v3_security: Some(security.clone()),
1849            ..ClientConfig::default()
1850        };
1851        let client = Client::new(transport, config);
1852        {
1853            // Cached remote capacity differs from the local transport limit.
1854            let state =
1855                EngineState::with_msg_max_size(Bytes::from_static(ENGINE_ID), 5, 1000, 9000);
1856            let derived_keys = security.derive_keys(ENGINE_ID).unwrap();
1857            *client.inner.engine.write().unwrap() = Some(ClientEngine {
1858                state,
1859                derived_keys,
1860            });
1861        }
1862
1863        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 1)]);
1864        let request = client.build_v3_message(&pdu, 1, None).unwrap();
1865        let msg = V3Message::decode(Bytes::from(request.data)).unwrap();
1866        assert_eq!(
1867            msg.global_data.msg_max_size, 1400,
1868            "request must advertise the local transport capacity, not the remote's cached 9000"
1869        );
1870    }
1871
1872    /// A received message claiming authentication on a client configured
1873    /// without authentication must be rejected for the missing capability,
1874    /// not processed unauthenticated (RFC 3412 Section 7.2 processes at the
1875    /// received level).
1876    #[tokio::test]
1877    async fn v3_noauth_client_rejects_received_auth_response() {
1878        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 1)]);
1879        let response = build_response(PduType::Response, 123, 1, 1001, Some(b"authpass12345678"));
1880        let client = canned_client(response, 1, 1000, UsmConfig::new("user"));
1881
1882        let err = client.send_v3_and_recv(pdu).await.unwrap_err();
1883        assert!(
1884            matches!(*err, Error::Auth { .. }),
1885            "expected Auth error for unverifiable received auth, got: {err}"
1886        );
1887    }
1888
1889    /// RFC 3416 Section 4.2: an echoed request-type PDU with a matching
1890    /// request-id is not a Response and must be rejected.
1891    #[tokio::test]
1892    async fn v3_rejects_echoed_request_pdu() {
1893        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 1)]);
1894        let response = build_response(PduType::GetRequest, 123, 1, 1001, None);
1895        let client = canned_client(response, 1, 1000, UsmConfig::new("user"));
1896
1897        let err = client.send_v3_and_recv(pdu).await.unwrap_err();
1898        assert!(
1899            matches!(*err, Error::MalformedResponse { .. }),
1900            "expected MalformedResponse, got: {err}"
1901        );
1902    }
1903
1904    /// Control for the timeliness test: an authenticated response with a
1905    /// fresh engine time passes.
1906    #[tokio::test]
1907    async fn v3_accepts_timely_authenticated_response() {
1908        let security = UsmConfig::new("user").auth(AuthProtocol::Sha1, "authpass12345678");
1909        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 1)]);
1910        let response = build_response(PduType::Response, 123, 1, 1200, Some(b"authpass12345678"));
1911        let client = canned_client(response, 1, 1000, security);
1912
1913        let result = client.send_v3_and_recv(pdu).await;
1914        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1915    }
1916
1917    /// RFC 3414 Section 3.2 Step 7b: an authenticated response whose engine
1918    /// time is more than 150 seconds behind the local notion is a replay and
1919    /// must be rejected.
1920    #[tokio::test]
1921    async fn v3_rejects_stale_authenticated_response() {
1922        let security = UsmConfig::new("user").auth(AuthProtocol::Sha1, "authpass12345678");
1923        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 1)]);
1924        // Local notion is time 1000; 500 is beyond the 150-second window.
1925        let response = build_response(PduType::Response, 123, 1, 500, Some(b"authpass12345678"));
1926        let client = canned_client(response, 1, 1000, security);
1927
1928        let err = client.send_v3_and_recv(pdu).await.unwrap_err();
1929        assert!(
1930            matches!(*err, Error::Auth { .. }),
1931            "expected Auth error, got: {err}"
1932        );
1933    }
1934}