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