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}