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