Skip to main content

matter_controller/
provider_server.rs

1//! The OTA **provider server** (M9-F3): a dedicated task that advertises our
2//! operational service, accepts an inbound CASE session as the responder, and
3//! dispatches one server-side `InvokeRequest`. Productionizes the responder
4//! accept-flow proven in the actor's loopback tests; hosts it in
5//! `matter-controller` so it can reuse the persisted operational identity
6//! (`crate::credentials::operational_credentials`) and the existing session /
7//! transport / discovery machinery without a new crate boundary.
8
9use std::net::{IpAddr, SocketAddr};
10use std::time::Instant;
11
12use matter_cert::{MatterTime, TrustedRoots};
13use matter_commissioning::driver::{decode_unsecured, encode_unsecured_reply, AsyncDatagram};
14use matter_crypto::{CaseCredentials, CaseResponder, ResumptionRecord, Sigma1Outcome};
15use matter_interaction::{
16    build_invoke_response_command, build_invoke_response_status, parse_invoke_request, CommandPath,
17    ImStatus, ParsedInvokeRequest,
18};
19use matter_transport::{
20    DecodeInboundOutput, MatterService, MrpFlags, ProtocolId, ServiceKind, SessionId,
21    SessionManager, SessionRole,
22};
23
24use crate::error::Error;
25
26// SecureChannel handshake opcodes (Matter Core §4.10 / §4.13).
27const OP_SIGMA1: u8 = 0x30;
28const OP_SIGMA2: u8 = 0x31;
29const OP_SIGMA3: u8 = 0x32;
30const OP_SIGMA2_RESUME: u8 = 0x33;
31const OP_STATUS_REPORT: u8 = 0x40;
32const OP_MRP_STANDALONE_ACK: u8 = 0x10;
33// Interaction Model opcodes.
34const OP_INVOKE_REQUEST: u8 = 0x08;
35
36/// Frames discarded while awaiting a Sigma1 before the accept fails. Stray
37/// LAN datagrams to the advertised port (undecodable noise, stale acks,
38/// leftovers from a discarded session) must not consume pooled credentials —
39/// but a flooder should still hit a bound rather than spin the accept
40/// forever.
41const MAX_AWAIT_SIGMA1_DISCARDS: usize = 64;
42const OP_INVOKE_RESPONSE: u8 = 0x09;
43
44// Secure-Channel StatusReport general codes (Matter Core §4.11.6).
45const STATUS_GENERAL_FAILURE: u16 = 0x0001;
46
47/// Encode the fixed 8-byte Secure-Channel `StatusReport` body (Matter Core
48/// §4.11.6): `GeneralCode` (u16 LE) || `ProtocolId` (u32 LE, `vendor<<16 |
49/// protocol`) || `ProtocolStatus` (u16 LE). Used to abort a BDX transfer
50/// (BDX-3) so the peer learns the failure instead of timing out.
51fn encode_status_report_body(general: u16, proto: ProtocolId, protocol_status: u16) -> Vec<u8> {
52    let proto_id: u32 = (u32::from(proto.vendor) << 16) | u32::from(proto.protocol);
53    let mut body = Vec::with_capacity(8);
54    body.extend_from_slice(&general.to_le_bytes());
55    body.extend_from_slice(&proto_id.to_le_bytes());
56    body.extend_from_slice(&protocol_status.to_le_bytes());
57    body
58}
59
60/// Parse the fixed 8-byte Secure-Channel `StatusReport` body into
61/// `(general_code, protocol_id, protocol_status)`. Returns `None` if the body
62/// is shorter than 8 bytes.
63fn parse_status_report_body(payload: &[u8]) -> Option<(u16, u32, u16)> {
64    let b: &[u8; 8] = payload.get(..8)?.try_into().ok()?;
65    Some((
66        u16::from_le_bytes([b[0], b[1]]),
67        u32::from_le_bytes([b[2], b[3], b[4], b[5]]),
68        u16::from_le_bytes([b[6], b[7]]),
69    ))
70}
71
72// OtaSoftwareUpdateProvider (0x0029) command ids (Matter Core §11.20).
73const OTA_PROVIDER_CLUSTER: u32 = 0x0029;
74const CMD_QUERY_IMAGE: u32 = 0x00;
75const CMD_QUERY_IMAGE_RESPONSE: u32 = 0x01;
76const CMD_APPLY_UPDATE_REQUEST: u32 = 0x02;
77const CMD_APPLY_UPDATE_RESPONSE: u32 = 0x03;
78const CMD_NOTIFY_UPDATE_APPLIED: u32 = 0x04;
79
80/// True when `frame` is an unsecured (session id 0) message — i.e. a new
81/// session-establishment attempt arriving while a secured session is being
82/// served. Bytes 1..3 are the little-endian session id (Matter Core §4.4.1).
83fn is_unsecured_frame(frame: &[u8]) -> bool {
84    frame.len() >= 3 && frame[1] == 0 && frame[2] == 0
85}
86
87/// A raw datagram (frame bytes + sender) handed from one accept to the next,
88/// so no handshake bytes are lost across session boundaries.
89type CarriedFrame = (Vec<u8>, SocketAddr);
90
91/// Build the operational `_matter._tcp` mDNS record to advertise so a requestor
92/// can resolve us. Instance name is `<compressed-fabric-id>-<node-id>` in
93/// uppercase hex (Matter Core §4.3.1), matching what the controller's initiator
94/// resolves against via `operational_instance_name`.
95#[must_use]
96pub fn build_operational_service(
97    compressed_fabric_id: [u8; 8],
98    node_id: u64,
99    addresses: Vec<IpAddr>,
100    port: u16,
101) -> MatterService {
102    let instance_name =
103        matter_commissioning::driver::operational_instance_name(compressed_fabric_id, node_id);
104    // Operational TXT params (SII/SAI/SAT) are optional hints; F3 advertises
105    // none (the requestor resolves us by SRV + A/AAAA). F4/hardening can add
106    // session-interval hints if a requestor needs them.
107    MatterService::new(
108        instance_name,
109        ServiceKind::Operational,
110        addresses,
111        port,
112        std::collections::HashMap::new(),
113    )
114}
115
116/// A multi-session OTA provider server: accepts inbound CASE sessions as the
117/// responder (one per pooled credential), then dispatches server-side
118/// `InvokeRequest`s. Generic over the datagram transport so it runs over
119/// `TokioUdpTransport` in production and `InMemoryDatagram` in tests.
120///
121/// This productionizes the responder accept-flow proven in the actor's loopback
122/// tests (`run_loopback_device`): Sigma1→Sigma2→Sigma3→`SessionManager` register,
123/// then secured IM dispatch on the established session.
124///
125/// The credential pool is consumed one entry per `accept_case` call. When the
126/// pool is exhausted, `accept_case` (and any caller such as `serve_ota_once`)
127/// returns [`Error::Operational`] with the message
128/// `"provider server: credential pool exhausted"`. The pool is sized by the
129/// caller — `serve_ota` mints four entries (first session + post-reboot session
130/// + retry slack) from the persisted fabric.
131pub struct ProviderServer<D> {
132    io: D,
133    /// Pool of operational identities, one consumed per CASE accept (the
134    /// responder state machine takes ownership of its credentials).
135    /// `serve_ota` mints these from the persisted fabric — see the spec's
136    /// sizing rationale (first session + post-reboot session + retry slack).
137    credentials: Vec<CaseCredentials>,
138    roots: TrustedRoots,
139    /// Base secured session id; accept N advertises `base.wrapping_add(N)` so
140    /// consecutive sessions never share a local id.
141    base_session_id: u16,
142    /// Number of accepts performed so far (also indexes the session id).
143    accepts: u16,
144    now: MatterTime,
145    handshake_counter: u32,
146    /// When set, an accepted session whose authenticated peer node id is not
147    /// this value fails the accept (its pooled credential is consumed — that
148    /// is the point: a fabric member other than the OTA target must not be
149    /// able to hijack the serve). `serve_ota` pins its `target_node_id`.
150    expected_peer: Option<u64>,
151    /// Known CASE resumption records. When an inbound Sigma1 carries
152    /// resumption fields whose id matches one of these, the session is
153    /// resumed (`Sigma2_Resume`) instead of a full handshake — chip's OTA
154    /// requestor always requests resumption of the session the controller
155    /// just used to announce, so `serve_ota` seeds this with the announce
156    /// connect's persisted record. No match falls back to
157    /// `reject_resumption` + full handshake.
158    resumption_records: Vec<ResumptionRecord>,
159    /// Invoked with the fresh [`ResumptionRecord`] each accept produces
160    /// (rotated on the resumed path, brand-new on the full path), so the
161    /// caller can persist it IMMEDIATELY — a caller-side timeout that drops
162    /// the serve future must not lose the rotation. Best-effort: the sink
163    /// must not block (spawn if it needs async work).
164    record_sink: Option<Box<dyn Fn(ResumptionRecord) + Send + Sync>>,
165}
166
167impl<D: AsyncDatagram> ProviderServer<D> {
168    /// Build a provider server bound to `io`, authenticating from the
169    /// `credentials` pool (our operational identities). `roots` and `now` are
170    /// used to validate the peer's certificate chain on each accept.
171    ///
172    /// `base_session_id` is the first secured session id advertised in Sigma2;
173    /// the Nth accept uses `base_session_id.wrapping_add(N)` so consecutive
174    /// sessions never reuse the same local id.
175    ///
176    /// The pool is consumed one entry per accept. When it is empty, the next
177    /// call to [`Self::serve_ota_once`] (or any method that calls `accept_case`)
178    /// returns an [`Error::Operational`] containing
179    /// `"provider server: credential pool exhausted"`.
180    #[must_use]
181    pub fn new(
182        io: D,
183        credentials: Vec<CaseCredentials>,
184        roots: TrustedRoots,
185        base_session_id: u16,
186        now: MatterTime,
187    ) -> Self {
188        Self {
189            io,
190            credentials,
191            roots,
192            base_session_id,
193            accepts: 0,
194            now,
195            handshake_counter: 1,
196            expected_peer: None,
197            resumption_records: Vec::new(),
198            record_sink: None,
199        }
200    }
201
202    /// Register a callback that is invoked once per completed accept with the
203    /// fresh [`ResumptionRecord`] the handshake produced (rotated on the resumed
204    /// path, brand-new on the full path). The caller can use this to persist the
205    /// record immediately — a future that is cancelled after `accept_case`
206    /// completes but before the caller stores the record would otherwise lose the
207    /// rotation. The sink is called synchronously and **must not block**; spawn
208    /// an async task if async work is needed.
209    #[must_use]
210    pub fn with_record_sink(mut self, sink: Box<dyn Fn(ResumptionRecord) + Send + Sync>) -> Self {
211        self.record_sink = Some(sink);
212        self
213    }
214
215    /// Seed the server with known CASE resumption records (see the field
216    /// docs). An inbound resumption-requesting Sigma1 matching one of these
217    /// by id is accepted via `Sigma2_Resume`; anything else falls back to a
218    /// full handshake.
219    #[must_use]
220    pub fn with_resumption_records(mut self, records: Vec<ResumptionRecord>) -> Self {
221        self.resumption_records = records;
222        self
223    }
224
225    /// Pin the peer: an accepted session must authenticate as `node_id` or
226    /// the accept fails (consuming its pooled credential). Without this, any
227    /// member of the fabric could consume the serve.
228    #[must_use]
229    pub fn with_expected_peer(mut self, node_id: u64) -> Self {
230        self.expected_peer = Some(node_id);
231        self
232    }
233
234    fn next_handshake_counter(&mut self) -> u32 {
235        let c = self.handshake_counter;
236        self.handshake_counter = self.handshake_counter.wrapping_add(1);
237        c
238    }
239
240    async fn recv(&self) -> Result<(Vec<u8>, SocketAddr), Error> {
241        self.io
242            .recv_from()
243            .await
244            .map_err(|e| Error::Operational(format!("provider recv: {e}")))
245    }
246
247    async fn send(&self, bytes: &[u8], peer: SocketAddr) -> Result<(), Error> {
248        self.io
249            .send_to(bytes, peer)
250            .await
251            .map_err(|e| Error::Operational(format!("provider send: {e}")))
252    }
253
254    /// Receive the next datagram while driving the session's MRP timers, so
255    /// scheduled standalone acks (and retransmits) fire even while we sit in
256    /// `recv`. Load-bearing for the OTA flow: the requestor's `BlockAckEOF`
257    /// is MRP-reliable and we reply with nothing — without the pumped
258    /// standalone ack, chip retransmits it, marks the session defunct, and
259    /// abandons the update before `ApplyUpdateRequest` (observed live).
260    async fn recv_secured(
261        &self,
262        sessions: &mut SessionManager,
263        peer: SocketAddr,
264    ) -> Result<(Vec<u8>, SocketAddr), Error> {
265        use matter_transport::MrpEvent;
266        loop {
267            let Some(deadline) = sessions.poll_timeout() else {
268                return self.recv().await;
269            };
270            let wait = deadline.saturating_duration_since(Instant::now());
271            match tokio::time::timeout(wait, self.recv()).await {
272                Ok(result) => return result,
273                Err(_deadline_hit) => {
274                    for event in sessions.handle_timeout(Instant::now()) {
275                        match event {
276                            MrpEvent::Retransmit { packet, .. }
277                            | MrpEvent::SendStandaloneAck { packet, .. } => {
278                                self.send(&packet, peer).await?;
279                            }
280                            // Single-session server: nothing to resolve on
281                            // expiry; `MrpEvent` is non_exhaustive.
282                            _ => {}
283                        }
284                    }
285                }
286            }
287        }
288    }
289
290    /// Accept ONE inbound CASE session as the responder, returning an
291    /// established [`SessionManager`] + the secured `SessionId` + the peer's
292    /// address. Mirrors the proven `run_loopback_device` accept-flow on the full
293    /// path; a Sigma1 carrying resumption fields that match a seeded record (see
294    /// [`Self::with_resumption_records`]) takes the `Sigma2_Resume` fast path
295    /// instead.
296    ///
297    /// The fresh [`ResumptionRecord`] the handshake produces is handled
298    /// internally: it is re-seeded into `self.resumption_records` (so the NEXT
299    /// accept can match it) and passed to the `record_sink` (if set) before this
300    /// method returns.
301    ///
302    /// If `first_frame` is `Some`, that datagram is used as the Sigma1 instead
303    /// of calling `recv` — useful for callers that have already peeked the first
304    /// packet (e.g., a multi-session loop that demuxes by session id).
305    ///
306    /// The returned [`CarriedFrame`] is `Some` when the full-handshake close
307    /// saw a NEW Sigma1 in place of the initiator's standalone ack (see
308    /// [`Self::complete_full`]); the caller must feed it into its next accept
309    /// or the handshake attempt it opens is lost.
310    async fn accept_case(
311        &mut self,
312        first_frame: Option<CarriedFrame>,
313    ) -> Result<(SessionManager, SessionId, SocketAddr, Option<CarriedFrame>), Error> {
314        // Fast-fail an exhausted pool before any IO. The check does NOT pop:
315        // a credential is consumed only once a valid Sigma1 is in hand, so
316        // stray datagrams to the advertised port cannot burn the pool.
317        if self.credentials.is_empty() {
318            return Err(Error::Operational(
319                "provider server: credential pool exhausted".into(),
320            ));
321        }
322
323        // Await a valid Sigma1, discarding anything else (undecodable noise,
324        // stray acks, stale secured frames) within a bounded budget.
325        let mut carried = first_frame;
326        let mut discarded = 0usize;
327        let (m1, peer) = loop {
328            let (bytes, from) = match carried.take() {
329                Some(f) => f,
330                None => self.recv().await?,
331            };
332            match decode_unsecured(&bytes) {
333                Ok(m) if m.opcode == OP_SIGMA1 => break (m, from),
334                _ => {
335                    discarded += 1;
336                    if discarded >= MAX_AWAIT_SIGMA1_DISCARDS {
337                        return Err(Error::Operational(format!(
338                            "no Sigma1 within {MAX_AWAIT_SIGMA1_DISCARDS} frames"
339                        )));
340                    }
341                }
342            }
343        };
344
345        // A real handshake attempt is starting: consume one pooled identity.
346        let credentials = self.credentials.remove(0);
347        let responder_session_id = self.base_session_id.wrapping_add(self.accepts);
348        self.accepts = self.accepts.wrapping_add(1);
349        let mut responder = CaseResponder::new(
350            credentials,
351            self.roots.clone(),
352            responder_session_id,
353            self.now,
354        )
355        .map_err(|e| Error::Operational(format!("CASE responder init: {e}")))?;
356
357        let outcome = responder
358            .handle_sigma1(&m1.payload)
359            .map_err(|e| Error::Operational(format!("handle_sigma1: {e}")))?;
360
361        let resumed = match outcome {
362            Sigma1Outcome::NewSession => false,
363            Sigma1Outcome::ResumptionRequested { id } => {
364                if let Some(pos) = self.resumption_records.iter().position(|r| r.id == id) {
365                    let record = self.resumption_records.swap_remove(pos);
366                    responder
367                        .accept_resumption(record)
368                        .map_err(|e| Error::Operational(format!("accept_resumption: {e}")))?;
369                    true
370                } else {
371                    // Unknown id — decline and fall back to a full handshake.
372                    responder
373                        .reject_resumption()
374                        .map_err(|e| Error::Operational(format!("reject_resumption: {e}")))?;
375                    false
376                }
377            }
378        };
379
380        let carry = if resumed {
381            self.complete_resumed(&mut responder, &m1, peer).await?;
382            None
383        } else {
384            self.complete_full(&mut responder, &m1, peer).await?
385        };
386
387        let output = responder
388            .finish()
389            .map_err(|e| Error::Operational(format!("CASE finish: {e}")))?;
390        // Enforce the pin BEFORE re-seeding/sinking the record: a rejected
391        // peer must leave no resumption state behind.
392        if let Some(expected) = self.expected_peer {
393            if output.peer.node_id != expected {
394                return Err(Error::Operational(format!(
395                    "provider server: accepted peer node {:#x} is not the expected {expected:#x}",
396                    output.peer.node_id
397                )));
398            }
399        }
400        if let Some(record) = output.resumption_record.clone() {
401            // Re-seed so the NEXT accept (the post-reboot requestor resumes
402            // with the id rotated during THIS handshake) can match it.
403            self.resumption_records.push(record.clone());
404            if let Some(sink) = &self.record_sink {
405                sink(record);
406            }
407        }
408        let mut sessions = SessionManager::new();
409        let sid = sessions.register_case(&output, SessionRole::Responder);
410        Ok((sessions, sid, peer, carry))
411    }
412
413    /// Resumed path: send `Sigma2_Resume` on Sigma1's exchange, then await the
414    /// initiator's success `StatusReport` and standalone-ack it (the report is
415    /// MRP-reliable; without our ack chip retransmits it and eventually tears
416    /// the exchange down). Tolerates interleaved Sigma1 retransmits (re-sends
417    /// `Sigma2_Resume`) and stray standalone acks.
418    async fn complete_resumed(
419        &mut self,
420        responder: &mut CaseResponder,
421        m1: &matter_commissioning::driver::UnsecuredMessage,
422        peer: SocketAddr,
423    ) -> Result<(), Error> {
424        let sigma2_resume = responder
425            .next_message()
426            .map_err(|e| Error::Operational(format!("sigma2_resume: {e}")))?;
427        let c = self.next_handshake_counter();
428        let wire = encode_unsecured_reply(
429            c,
430            m1.exchange_id,
431            OP_SIGMA2_RESUME,
432            ProtocolId::SECURE_CHANNEL,
433            true,
434            Some(m1.message_counter),
435            m1.source_node_id,
436            &sigma2_resume,
437        );
438        self.send(&wire, peer).await?;
439
440        // Await the initiator's SigmaFinished success StatusReport, within a
441        // bounded frame budget.
442        for _ in 0..8 {
443            let (bytes, _) = self.recv().await?;
444            let m = decode_unsecured(&bytes)
445                .map_err(|e| Error::Operational(format!("post-resume frame: {e}")))?;
446            match m.opcode {
447                OP_STATUS_REPORT => {
448                    // StatusReport body: GeneralCode(u16 LE) || ProtocolId(u32) || ProtocolCode(u16).
449                    let general_code = m
450                        .payload
451                        .get(0..2)
452                        .map(|b| u16::from_le_bytes([b[0], b[1]]))
453                        .ok_or_else(|| {
454                            Error::Operational("truncated resumption StatusReport".into())
455                        })?;
456                    if general_code != 0 {
457                        return Err(Error::Operational(format!(
458                            "initiator rejected resumption: StatusReport general code {general_code}"
459                        )));
460                    }
461                    // Ack the reliable report so the initiator's MRP settles.
462                    let c = self.next_handshake_counter();
463                    let ack = encode_unsecured_reply(
464                        c,
465                        m.exchange_id,
466                        OP_MRP_STANDALONE_ACK,
467                        ProtocolId::SECURE_CHANNEL,
468                        false,
469                        Some(m.message_counter),
470                        m.source_node_id.or(m1.source_node_id),
471                        &[],
472                    );
473                    self.send(&ack, peer).await?;
474                    return Ok(());
475                }
476                // Sigma1 retransmit: our Sigma2_Resume (or its ack) was lost —
477                // re-send it on the same exchange.
478                OP_SIGMA1 => {
479                    let c = self.next_handshake_counter();
480                    let wire = encode_unsecured_reply(
481                        c,
482                        m.exchange_id,
483                        OP_SIGMA2_RESUME,
484                        ProtocolId::SECURE_CHANNEL,
485                        true,
486                        Some(m.message_counter),
487                        m.source_node_id.or(m1.source_node_id),
488                        &sigma2_resume,
489                    );
490                    self.send(&wire, peer).await?;
491                }
492                // A standalone ack of our Sigma2_Resume — fine, keep waiting.
493                OP_MRP_STANDALONE_ACK => {}
494                other => {
495                    return Err(Error::Operational(format!(
496                        "expected resumption StatusReport (0x40), got {other:#04x}"
497                    )))
498                }
499            }
500        }
501        Err(Error::Operational(
502            "no StatusReport after Sigma2_Resume within frame budget".into(),
503        ))
504    }
505
506    /// Full-handshake path (Sigma2 → Sigma3 → our success `StatusReport`), used
507    /// for a plain Sigma1 and as the fallback after `reject_resumption`.
508    ///
509    /// Returns the frame to carry into the next accept when the closing
510    /// ack-absorb `recv` saw a NEW Sigma1 instead of the initiator's
511    /// standalone ack: a requestor that applies and reboots fast can have its
512    /// next handshake's Sigma1 in flight before the ack — eating it would
513    /// force the peer through an MRP retransmit round AND burn one pooled
514    /// retry credential on this side. Everything else (the ack, noise, a
515    /// same-exchange Sigma1 — a stale duplicate of `m1`, provably already
516    /// answered because Sigma3 arrived) is absorbed as before.
517    async fn complete_full(
518        &mut self,
519        responder: &mut CaseResponder,
520        m1: &matter_commissioning::driver::UnsecuredMessage,
521        peer: SocketAddr,
522    ) -> Result<Option<CarriedFrame>, Error> {
523        let sigma2 = responder
524            .next_message()
525            .map_err(|e| Error::Operational(format!("sigma2: {e}")))?;
526        let c = self.next_handshake_counter();
527        let wire = encode_unsecured_reply(
528            c,
529            m1.exchange_id,
530            OP_SIGMA2,
531            ProtocolId::SECURE_CHANNEL,
532            true,
533            Some(m1.message_counter),
534            m1.source_node_id,
535            &sigma2,
536        );
537        self.send(&wire, peer).await?;
538
539        // Sigma3 → success StatusReport.
540        let (s3, _) = self.recv().await?;
541        let m3 = decode_unsecured(&s3).map_err(|e| Error::Operational(format!("sigma3: {e}")))?;
542        if m3.opcode != OP_SIGMA3 {
543            return Err(Error::Operational(format!(
544                "expected Sigma3 (0x32), got {:#04x}",
545                m3.opcode
546            )));
547        }
548        responder
549            .handle_sigma3(&m3.payload)
550            .map_err(|e| Error::Operational(format!("handle_sigma3: {e}")))?;
551        let mut body = Vec::with_capacity(8);
552        body.extend_from_slice(&0u16.to_le_bytes()); // GeneralCode: success
553        body.extend_from_slice(&0u32.to_le_bytes()); // ProtocolId: SecureChannel
554        body.extend_from_slice(&0u16.to_le_bytes()); // ProtocolCode: 0
555        let c = self.next_handshake_counter();
556        let report = encode_unsecured_reply(
557            c,
558            m3.exchange_id,
559            OP_STATUS_REPORT,
560            ProtocolId::SECURE_CHANNEL,
561            true,
562            Some(m3.message_counter),
563            m3.source_node_id.or(m1.source_node_id),
564            &body,
565        );
566        self.send(&report, peer).await?;
567
568        // Absorb the initiator's standalone ack of our StatusReport — but hand
569        // a fresh Sigma1 (new handshake, new exchange) back to the caller
570        // instead of eating it (see the method docs).
571        let (bytes, from) = self.recv().await?;
572        if let Ok(m) = decode_unsecured(&bytes) {
573            if m.opcode == OP_SIGMA1 && m.exchange_id != m1.exchange_id {
574                return Ok(Some((bytes, from)));
575            }
576        }
577        Ok(None)
578    }
579
580    /// Accept ONE inbound CASE session, then dispatch up to `max_invokes`
581    /// server-side `InvokeRequest`s through `handler`, replying to each on its
582    /// exchange. Returns the number of invokes dispatched.
583    ///
584    /// `handler` maps a parsed `InvokeRequest` to the encoded `InvokeResponse`
585    /// message bytes (e.g. via `matter_interaction::build_invoke_response_*`).
586    ///
587    /// # Errors
588    ///
589    /// Returns [`Error::Operational`] on a transport, CASE-handshake, or framing
590    /// failure (including a non-`NewSession` Sigma1 or an unexpected opcode), or
591    /// [`Error::Transport`] / [`Error::InteractionModel`] from the session / IM
592    /// layers.
593    pub async fn accept_and_dispatch_once<H>(
594        mut self,
595        mut handler: H,
596        max_invokes: usize,
597    ) -> Result<usize, Error>
598    where
599        H: FnMut(&ParsedInvokeRequest) -> Vec<u8>,
600    {
601        // Single-session API: there is no next accept to feed a carried
602        // Sigma1 into, so it is dropped (the peer's MRP retransmit covers it)
603        // — the pre-multi-session behavior.
604        let (mut sessions, sid, peer, _fast_sigma1) = self.accept_case(None).await?;
605
606        let mut dispatched = 0usize;
607        while dispatched < max_invokes {
608            let (wire, _) = self.recv_secured(&mut sessions, peer).await?;
609            if let DecodeInboundOutput::AppMessage {
610                exchange_id,
611                opcode,
612                payload,
613                ..
614            } = sessions.decode_inbound(&wire, Instant::now())?
615            {
616                if opcode != OP_INVOKE_REQUEST {
617                    // Ignore non-invoke app messages in F3 (e.g. reads).
618                    continue;
619                }
620                let parsed = parse_invoke_request(&payload)?;
621                let response = handler(&parsed);
622                let out = sessions.encode_outbound(
623                    sid,
624                    Some(exchange_id),
625                    OP_INVOKE_RESPONSE,
626                    ProtocolId::INTERACTION_MODEL,
627                    &response,
628                    MrpFlags { reliable: false },
629                    Instant::now(),
630                )?;
631                self.send(&out.wire_bytes, peer).await?;
632                dispatched += 1;
633            }
634        }
635        Ok(dispatched)
636    }
637
638    /// Accept CASE sessions in sequence, serving `image` to the requestor over the
639    /// full OTA flow — `QueryImage` → `QueryImageResponse`, a BDX transfer, then
640    /// `ApplyUpdateRequest` → `ApplyUpdateResponse` (Proceed) — and completing once
641    /// `NotifyUpdateApplied` is received on ANY session. A real requestor downloads
642    /// and applies on its first session, reboots into the new image, and sends
643    /// `NotifyUpdateApplied` on a fresh session; this method spans that reboot by
644    /// running an outer loop over `accept_case` calls.
645    ///
646    /// Unsecured frames (session id 0) arriving while a secured session is being
647    /// served are recognised as new-session-establishment attempts; they are
648    /// carried into the next outer iteration as the `first_frame` for the next
649    /// `accept_case` call, so no handshake bytes are lost.
650    ///
651    /// The caller owns the deadline: wrap `serve_ota_once` in
652    /// `tokio::time::timeout` (or similar) to bound a requestor that never
653    /// returns. Pool exhaustion (all credentials consumed) and a per-session step
654    /// budget are the two error paths.
655    ///
656    /// The fresh [`ResumptionRecord`] the accept handshake produced is re-seeded
657    /// and forwarded to the `record_sink` (if set via
658    /// [`Self::with_record_sink`]) before the OTA dispatch loop begins — the
659    /// caller need not wait for the full OTA flow to persist the rotation.
660    ///
661    /// `offer` shapes the `QueryImageResponse` (its `ImageURI`/`UpdateToken`);
662    /// `max_block_size` caps each BDX block. All replies are unreliable
663    /// (piggyback ack) — happy-path, localhost-validated. Messages route by
664    /// [`ProtocolId`]: Interaction-Model invokes go to the `matter-ota` handlers,
665    /// `ProtocolId::BDX` messages drive a [`matter_bdx::BlockSender`].
666    ///
667    /// # Errors
668    ///
669    /// [`Error::Operational`] on a CASE/transport/codec failure, a BDX abort, an
670    /// unexpected OTA command, or if a session exhausts its step budget without
671    /// an unsecured carry-frame; [`Error::Transport`] / [`Error::InteractionModel`]
672    /// from the session / IM layers.
673    #[allow(clippy::too_many_lines)] // Linear OTA protocol-dispatch loop; splitting hurts clarity.
674    pub async fn serve_ota_once(
675        mut self,
676        offer: matter_ota::ImageOffer,
677        image: Vec<u8>,
678        max_block_size: u16,
679    ) -> Result<(), Error> {
680        use matter_bdx::{BdxMessage, BlockSender, MessageType, SenderOutcome};
681
682        // Flow state spans sessions: the requestor downloads + applies on its
683        // first session, REBOOTS into the image, and sends NotifyUpdateApplied
684        // on a fresh session (usually resuming the record rotated during the
685        // first accept — re-seeded by accept_case).
686        let mut bdx: Option<BlockSender> = None;
687        let mut carried: Option<(Vec<u8>, SocketAddr)> = None;
688
689        // Outer: one iteration per CASE session; bounded by the credential
690        // pool (accept_case errors when it is exhausted). A failed mid-flow
691        // handshake poisons only that accept (spec: Error handling) — it
692        // consumed one pooled credential, and the loop waits for the peer's
693        // next attempt; only pool exhaustion (or the caller's deadline) ends
694        // the serve.
695        loop {
696            let (mut sessions, sid, peer, fast_sigma1) =
697                match self.accept_case(carried.take()).await {
698                    Ok(accepted) => accepted,
699                    Err(e) => {
700                        if self.credentials.is_empty() {
701                            return Err(e); // exhausted (or the last credential's failure)
702                        }
703                        continue; // retry with the next pooled credential
704                    }
705                };
706            if let Some(frame) = fast_sigma1 {
707                // The peer opened a NEW handshake instead of acking this one's
708                // close (fast post-reboot Sigma1 in place of the standalone
709                // ack): the session just established is already abandoned —
710                // roll the Sigma1 straight into the next accept rather than
711                // blocking the inner loop on a dead session.
712                carried = Some(frame);
713                continue;
714            }
715
716            // BDX-4: bound progress and iteration SEPARATELY. `max_progress`
717            // caps how many transfer-ADVANCING messages (OTA commands + blocks)
718            // we serve; a larger `max_iterations` backstop bounds frames that do
719            // NOT advance the transfer (stale prior-session retransmits, the
720            // duplicate-reliable ack resends BDX-2 handles, a peer StatusReport).
721            // Counting every frame against one budget — as the old `steps`
722            // did — let a lossy mesh's retransmits exhaust it before the last
723            // block arrived, turning a recoverable loss into a spurious failure.
724            let max_progress = image.len() / usize::from(max_block_size.max(1)) + 64;
725            let max_iterations = max_progress.saturating_mul(8).max(1024);
726            let mut progress = 0usize;
727            let mut iterations = 0usize;
728
729            // Inner: serve this session until Notify (done), a new handshake
730            // frame (roll into the next accept), or a bound.
731            while progress < max_progress && iterations < max_iterations {
732                iterations += 1;
733                let (wire, from) = self.recv_secured(&mut sessions, peer).await?;
734                if is_unsecured_frame(&wire) {
735                    carried = Some((wire, from));
736                    break;
737                }
738                // A frame that fails secured decode is a stale leftover — e.g.
739                // a late retransmit keyed to a PRIOR session's id after the
740                // requestor re-established (the reboot window) — not a fault
741                // of the live session. Skip it; the step budget bounds a
742                // pathological stream of them.
743                let Ok(decoded) = sessions.decode_inbound(&wire, Instant::now()) else {
744                    continue;
745                };
746                let DecodeInboundOutput::AppMessage {
747                    exchange_id,
748                    protocol_id,
749                    opcode,
750                    payload,
751                    ..
752                } = decoded
753                else {
754                    // BDX-2: the requestor retransmitted a reliable message
755                    // (e.g. a BlockQuery) whose ack was lost. decode_inbound has
756                    // pre-built the standalone ack to re-send — send it and do
757                    // NOT advance BDX state (the block counter already moved).
758                    // Dropping it here (the old `continue`) left the requestor
759                    // retransmitting forever, stalling the transfer. Other
760                    // non-app outcomes (AckOnly) need no response.
761                    if let DecodeInboundOutput::DuplicateReliableAckResent { ack_packet, .. } =
762                        decoded
763                    {
764                        self.send(&ack_packet, peer).await?;
765                    }
766                    continue;
767                };
768
769                // BDX-4: only a message that ADVANCES the transfer (an OTA
770                // invoke or a BDX message) counts against `max_progress`. A
771                // stale/duplicate frame `continue`s above without reaching here,
772                // so it burns only an `iterations` slot, never the progress
773                // budget.
774                let advanced = (protocol_id == ProtocolId::INTERACTION_MODEL
775                    && opcode == OP_INVOKE_REQUEST)
776                    || protocol_id == ProtocolId::BDX;
777
778                if protocol_id == ProtocolId::INTERACTION_MODEL && opcode == OP_INVOKE_REQUEST {
779                    let parsed = parse_invoke_request(&payload)?;
780                    let cmd = parsed
781                        .commands
782                        .first()
783                        .ok_or_else(|| Error::Operational("OTA invoke had no command".into()))?;
784                    let response = if cmd.path.command == CMD_QUERY_IMAGE {
785                        bdx = Some(BlockSender::new(image.clone(), max_block_size));
786                        let fields = matter_ota::handle_query_image(&cmd.fields_tlv, Some(&offer))
787                            .map_err(|e| Error::Operational(format!("QueryImage: {e}")))?;
788                        build_invoke_response_command(
789                            CommandPath {
790                                endpoint: 0,
791                                cluster: OTA_PROVIDER_CLUSTER,
792                                command: CMD_QUERY_IMAGE_RESPONSE,
793                            },
794                            &fields,
795                        )
796                    } else if cmd.path.command == CMD_APPLY_UPDATE_REQUEST {
797                        let fields = matter_ota::handle_apply_update_request(&cmd.fields_tlv)
798                            .map_err(|e| Error::Operational(format!("ApplyUpdateRequest: {e}")))?;
799                        build_invoke_response_command(
800                            CommandPath {
801                                endpoint: 0,
802                                cluster: OTA_PROVIDER_CLUSTER,
803                                command: CMD_APPLY_UPDATE_RESPONSE,
804                            },
805                            &fields,
806                        )
807                    } else if cmd.path.command == CMD_NOTIFY_UPDATE_APPLIED {
808                        matter_ota::parse_notify_update_applied(&cmd.fields_tlv)
809                            .map_err(|e| Error::Operational(format!("NotifyUpdateApplied: {e}")))?;
810                        let r = build_invoke_response_status(
811                            CommandPath {
812                                endpoint: 0,
813                                cluster: OTA_PROVIDER_CLUSTER,
814                                command: CMD_NOTIFY_UPDATE_APPLIED,
815                            },
816                            ImStatus::Success,
817                        );
818                        let out = sessions.encode_outbound(
819                            sid,
820                            Some(exchange_id),
821                            OP_INVOKE_RESPONSE,
822                            ProtocolId::INTERACTION_MODEL,
823                            &r,
824                            MrpFlags { reliable: false },
825                            Instant::now(),
826                        )?;
827                        self.send(&out.wire_bytes, peer).await?;
828                        return Ok(());
829                    } else {
830                        return Err(Error::Operational(format!(
831                            "unexpected OTA command {:#04x}",
832                            cmd.path.command
833                        )));
834                    };
835                    let out = sessions.encode_outbound(
836                        sid,
837                        Some(exchange_id),
838                        OP_INVOKE_RESPONSE,
839                        ProtocolId::INTERACTION_MODEL,
840                        &response,
841                        MrpFlags { reliable: false },
842                        Instant::now(),
843                    )?;
844                    self.send(&out.wire_bytes, peer).await?;
845                } else if protocol_id == ProtocolId::SECURE_CHANNEL && opcode == OP_STATUS_REPORT {
846                    // BDX-3 (receive): the requestor aborted via a Secure-Channel
847                    // StatusReport — e.g. a device-side flash-write failure. End
848                    // the transfer with a descriptive error naming the peer's
849                    // status, instead of ignoring it and spinning to the "step
850                    // budget exceeded" error (chip surfaces the peer status;
851                    // TestBdxTransferSession.cpp:629).
852                    let (general, proto, code) =
853                        parse_status_report_body(&payload).ok_or_else(|| {
854                            Error::Operational("BDX StatusReport body truncated".into())
855                        })?;
856                    return Err(Error::Operational(format!(
857                        "BDX transfer aborted by peer: StatusReport general={general:#06x} \
858                         protocol={proto:#010x} status={code:#06x}"
859                    )));
860                } else if protocol_id == ProtocolId::BDX {
861                    let mt = MessageType::from_u8(opcode).ok_or_else(|| {
862                        Error::Operational(format!("unknown BDX opcode {opcode:#04x}"))
863                    })?;
864                    let msg = BdxMessage::decode(mt, &payload)
865                        .map_err(|e| Error::Operational(format!("BDX decode: {e}")))?;
866                    // A `ReceiveInit` is a request to START a transfer. When a
867                    // sender is already armed but mid-transfer, the requestor
868                    // reconnected mid-download (reboot, link loss) and is
869                    // re-initiating BDX from its cached `QueryImageResponse`
870                    // URI without re-querying — re-arm and serve from the
871                    // start rather than aborting the serve (tolerant choice:
872                    // the image is static and the session authenticated — and
873                    // peer-pinned under `with_expected_peer` — so re-serving
874                    // the same bytes discloses nothing new). The DoS bound is
875                    // preserved: BDX still NEVER starts before this serve's
876                    // first `QueryImage` (`bdx` stays `None` until then), and
877                    // the per-session step budget bounds a requestor that
878                    // loops `ReceiveInit`.
879                    if matches!(msg, BdxMessage::ReceiveInit(_)) && bdx.is_some() {
880                        bdx = Some(BlockSender::new(image.clone(), max_block_size));
881                    }
882                    let sender = bdx.as_mut().ok_or_else(|| {
883                        Error::Operational("BDX message before QueryImage".into())
884                    })?;
885                    let outcome = match msg {
886                        BdxMessage::ReceiveInit(init) => sender.accept_receive_init(&init),
887                        BdxMessage::BlockQuery(q) => sender.handle_block_query(&q),
888                        BdxMessage::BlockAckEof(a) => sender.handle_block_ack_eof(&a),
889                        _ => {
890                            return Err(Error::Operational("unexpected inbound BDX message".into()))
891                        }
892                    };
893                    match outcome {
894                        SenderOutcome::Send(out) => {
895                            // BDX-1: send every BDX message MRP-reliable so a
896                            // lost block/ReceiveAccept is retransmitted (the
897                            // recv_secured loop pumps the MRP retransmit timer).
898                            // Over a lossy mesh (Thread) the first lost block
899                            // otherwise stalls the transfer forever. chip sends
900                            // every BDX message with kExpectResponse / never
901                            // kNoAutoRequestAck (AsyncTransferFacilitator.cpp:127).
902                            let w = sessions.encode_outbound(
903                                sid,
904                                Some(exchange_id),
905                                out.message_type.to_u8(),
906                                ProtocolId::BDX,
907                                &out.payload,
908                                MrpFlags { reliable: true },
909                                Instant::now(),
910                            )?;
911                            self.send(&w.wire_bytes, peer).await?;
912                        }
913                        SenderOutcome::Done => {}
914                        SenderOutcome::Abort(code) => {
915                            // BDX-3 (send): notify the peer with a Secure-Channel
916                            // StatusReport before bailing, so the requestor learns
917                            // the transfer failed instead of timing out. Best
918                            // effort — the abort error is returned regardless.
919                            let body = encode_status_report_body(
920                                STATUS_GENERAL_FAILURE,
921                                ProtocolId::BDX,
922                                code.to_u16(),
923                            );
924                            if let Ok(w) = sessions.encode_outbound(
925                                sid,
926                                Some(exchange_id),
927                                OP_STATUS_REPORT,
928                                ProtocolId::SECURE_CHANNEL,
929                                &body,
930                                MrpFlags { reliable: true },
931                                Instant::now(),
932                            ) {
933                                let _ = self.send(&w.wire_bytes, peer).await;
934                            }
935                            return Err(Error::Operational(format!(
936                                "BDX transfer aborted: status {:#06x}",
937                                code.to_u16()
938                            )));
939                        }
940                    }
941                }
942
943                if advanced {
944                    progress += 1;
945                }
946            }
947            if carried.is_none() {
948                return Err(Error::Operational(format!(
949                    "OTA session ended without completing: served {progress}/{max_progress} \
950                     transfer-advancing messages in {iterations}/{max_iterations} iterations"
951                )));
952            }
953        }
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    #![allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md carve-out.
960    use super::*;
961    use std::net::Ipv6Addr;
962
963    #[test]
964    fn operational_service_has_expected_name_kind_and_port() {
965        let compressed = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE];
966        let node_id = 0x0000_0000_0000_0001;
967        let addr = IpAddr::V6(Ipv6Addr::LOCALHOST);
968        let svc = build_operational_service(compressed, node_id, vec![addr], 5540);
969
970        assert_eq!(svc.kind, ServiceKind::Operational);
971        assert_eq!(svc.port, 5540);
972        // <16-hex compressed>-<16-hex node>, uppercase.
973        assert_eq!(svc.instance_name, "DEADBEEFCAFEBABE-0000000000000001");
974        assert_eq!(svc.addresses, vec![addr]);
975    }
976
977    #[test]
978    fn status_report_body_byte_layout_and_roundtrip() {
979        // BDX-3: a BDX abort StatusReport = Failure || BDX proto id (0x00000002)
980        // || the 16-bit BDX status. Byte layout is little-endian per field
981        // (Matter Core §4.11.6).
982        let code = matter_bdx::BdxStatusCode::BadBlockCounter.to_u16(); // 0x0017
983        let body = encode_status_report_body(STATUS_GENERAL_FAILURE, ProtocolId::BDX, code);
984        assert_eq!(
985            body,
986            vec![0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x17, 0x00],
987            "GeneralCode(LE) || ProtocolId(LE u32) || ProtocolStatus(LE)"
988        );
989        assert_eq!(
990            parse_status_report_body(&body),
991            Some((STATUS_GENERAL_FAILURE, 0x0000_0002, code))
992        );
993    }
994
995    #[test]
996    fn status_report_body_rejects_truncated() {
997        assert_eq!(parse_status_report_body(&[0x01, 0x00, 0x02]), None);
998        assert_eq!(parse_status_report_body(&[]), None);
999    }
1000
1001    /// `is_unsecured_frame` returns true for session id 0 (unsecured), false
1002    /// for a non-zero session id (secured), and false for a short slice.
1003    #[test]
1004    fn is_unsecured_frame_classifies_correctly() {
1005        // True: encode_unsecured_reply always sets session id to 0.
1006        let unsecured = encode_unsecured_reply(
1007            1,
1008            1,
1009            0x30,
1010            ProtocolId::SECURE_CHANNEL,
1011            false,
1012            None,
1013            None,
1014            &[],
1015        );
1016        assert!(
1017            is_unsecured_frame(&unsecured),
1018            "unsecured reply must have session id 0"
1019        );
1020
1021        // False: hand-built frame with session id 0x1234 (LE at bytes[1..3]).
1022        let secured = vec![0x00u8, 0x34, 0x12, 0x00, 0x00, 0x00];
1023        assert!(
1024            !is_unsecured_frame(&secured),
1025            "non-zero session id must not be classified as unsecured"
1026        );
1027
1028        // False: slice shorter than 3 bytes.
1029        assert!(
1030            !is_unsecured_frame(&[0x00u8, 0x00]),
1031            "2-byte slice must return false"
1032        );
1033    }
1034
1035    /// An empty credential pool must fail fast (before any IO) with the
1036    /// canonical error message. This exercises the pool-exhaustion guard in
1037    /// `accept_case` without requiring a real CASE peer.
1038    #[tokio::test]
1039    async fn empty_credential_pool_errors_before_any_io() {
1040        let (io, _peer) = matter_commissioning::driver::InMemoryDatagram::pair();
1041        let server = ProviderServer::new(
1042            io,
1043            Vec::new(),
1044            TrustedRoots::new(),
1045            0x10,
1046            MatterTime::from_unix_secs(2_000_000_000),
1047        );
1048        let offer = matter_ota::ImageOffer {
1049            software_version: 2,
1050            software_version_string: "2.0".into(),
1051            image_uri: "bdx://0/fw.ota".into(),
1052            update_token: vec![0xAB; 16],
1053        };
1054        let err = server
1055            .serve_ota_once(offer, vec![0u8; 16], 960)
1056            .await
1057            .expect_err("empty pool must fail fast");
1058        assert!(
1059            err.to_string().contains("credential pool exhausted"),
1060            "unexpected error: {err}"
1061        );
1062    }
1063}