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