offline_protocol/protocol/receive.rs
1//! Message receive loop and file chunk handling.
2
3use super::mesh_relay::RelayAdmission as MeshRelayAdmission;
4use super::{
5 internal_prefixes, lock_shared_state, ChunkOutcome, InternalMessageResult, OfflineProtocol,
6 PendingMediaMetadataEntry, ProtocolState, RichPayloadV1, CRITICAL_RELAY_BATTERY_LEVEL,
7};
8use crate::constants::{ACK_FOR_KEY, RELAY_LEARNED_ROUTE_QUALITY};
9use crate::events::{Event, SecurityWarningCode};
10use crate::file_transfer::FileChunk;
11use crate::media_envelope::{decode_media_envelope, is_media_envelope, MediaChunkPlaintext};
12use crate::mls_observability::{DecryptionFailureKind, MlsErrorCategory, MlsOperationContext};
13use crate::SessionStateError;
14use offline_protocol_core::{ContentType, MediaMetadata, Message};
15use offline_protocol_mls::{EncryptedMessage, GroupId};
16use offline_protocol_router::relay::RelayPriority;
17use offline_protocol_transport::TransportType;
18use std::time::Instant;
19use tracing::{debug, error, info, warn};
20
21impl OfflineProtocol {
22 /// Applies a successful MLS decryption to an inbound message: swaps the
23 /// ciphertext for the plaintext, marks the message encrypted, drops the
24 /// outer `reply_context`, and restores rich fields from a sealed
25 /// `__RICH_V1__` body when present.
26 ///
27 /// The outer `reply_context` field is hop-visible cleartext that any
28 /// relay can inject or rewrite in transit — it sits outside the MLS AEAD
29 /// boundary. The strip happens unconditionally *before* the sealed-body
30 /// restore below, so the encrypted envelope is the only trusted carrier
31 /// for reply context on encrypted messages. Without this strip, a
32 /// `MessageReceived { encrypted: true }` event could surface an
33 /// attacker-controlled quote preview as if it were part of the
34 /// authenticated conversation.
35 ///
36 /// Sealed-body parsing is never capability-gated (mirroring envelope
37 /// parsing): whatever a peer chose to seal, we try to read. On a rich
38 /// message the sealed body is authoritative for `reply_context`,
39 /// `media_metadata`, and `forwarded_from` — the relay-writable outer
40 /// copies are overwritten wholesale, `None`s included — and for
41 /// `content_type` when the body carries one (absent from bodies sealed
42 /// by senders predating the field, where the outer hint stands). The
43 /// sealed `content_type` restore runs before the receive loop's
44 /// `FileChunk` routing, so a relay restamping an encrypted rich
45 /// message's outer hint can no longer misroute it; `FileChunk` itself
46 /// is refused from the sealed body (mirroring the send boundary), or a
47 /// hostile sender could steer an ordinary message into the
48 /// file-transfer manager, which drops it. A body that fails to parse
49 /// (including a hostile `reply_context.sender` rejected by `UserId`
50 /// validation) surfaces as raw text with a warning rather than
51 /// dropping an authenticated message.
52 pub(super) fn apply_decrypted_content(message: &mut Message, plaintext: String) {
53 message.reply_context = None;
54 if plaintext.starts_with(internal_prefixes::RICH_V1) {
55 match RichPayloadV1::parse_sealed(&plaintext, message.sender.as_str()) {
56 Some(rich) => {
57 message.content = rich.text;
58 message.reply_context = rich.reply_context;
59 message.media_metadata = rich.media_metadata;
60 message.forwarded_from = rich.forward_info;
61 // `parse_sealed` already refused a FileChunk claim; a
62 // remaining None keeps the outer value (bodies sealed by
63 // senders predating the field).
64 if let Some(content_type) = rich.content_type {
65 message.content_type = content_type;
66 }
67 }
68 None => {
69 message.content = plaintext;
70 }
71 }
72 } else {
73 message.content = plaintext;
74 }
75 message
76 .metadata
77 .insert("encrypted".to_string(), "true".to_string());
78 }
79
80 /// Receives the next available message.
81 pub fn receive_message(&mut self) -> Option<Message> {
82 let Ok(mut state) = lock_shared_state(&self.shared_state) else {
83 error!("Failed to lock shared state in receive_message");
84 return None;
85 };
86 let protocol_running = state.state == ProtocolState::Running;
87
88 if !state.received_messages.is_empty() {
89 return state.received_messages.pop_front();
90 }
91
92 drop(state);
93
94 // Drive confirmation maintenance from receive polling as an additional
95 // liveness source when the app does not call process() on a timer.
96 // Uses the same throttle as process() to avoid redundant storage I/O.
97 if protocol_running {
98 self.run_throttled_reconciliation("receive_message_poll");
99 }
100
101 loop {
102 match self.transport_manager.receive() {
103 Ok(Some((transport_used, mut message))) => {
104 // The peer whose link this frame physically arrived on.
105 // Distinct from `message.sender`, which is who wrote it:
106 // the two agree only at the first hop. `None` when the
107 // carrier does not identify links (Nostr) or the platform
108 // did not supply an id.
109 let arrival_peer = message.transport_peer_id().map(str::to_string);
110
111 // Traffic for someone else is forwarded, not processed, and
112 // that decision comes first.
113 //
114 // Everything below this point treats the frame as part of
115 // our own exchange: it merges the sender's clock into ours,
116 // settles our outbox from acknowledgements, and consumes the
117 // frame. None of that is true of a frame merely passing
118 // through us — its acknowledgements belong to the pair it
119 // travels between, and once a node carries the whole
120 // neighborhood's traffic, absorbing every clock it sees
121 // would drag ours to the network's maximum.
122 //
123 // Never forward a frame claiming our own origin: a genuine
124 // self-originated frame is never received inbound with a
125 // foreign recipient (the send path does not loop back), so
126 // this is a routing loop or a forgery aimed at
127 // `internet_control_op`'s self-origination gate — re-issuing
128 // it from our outbox would let a mesh peer drive
129 // relay-native control ops on our authenticated relay
130 // connection. (A sender==self frame addressed *to* us is the
131 // legitimate relay echo and falls through unchanged.)
132 if message.recipient.as_str() != self.local_id {
133 if message.sender.as_str() == self.local_id {
134 debug!(
135 message_id = %message.id,
136 recipient = %message.recipient,
137 "Dropping inbound frame forging our own origin"
138 );
139 continue;
140 }
141 self.try_relay_message(&message, arrival_peer.as_deref());
142 continue;
143 }
144
145 // Block filter (early): check before any side-effects so
146 // that blocked users cannot advance our Lamport clock,
147 // leak our presence via re-ACK, or trigger any processing.
148 let sender_blocked = self.is_user_blocked(message.sender.as_str());
149
150 // Merge Lamport clock for every non-blocked received message
151 // — including duplicates, ACKs, and internal protocol
152 // messages — so the local clock always advances past any
153 // observed peer value.
154 if !sender_blocked && message.lamport_clock.value() > 0 {
155 self.lamport_clock.merge(message.lamport_clock);
156 self.persist_lamport_clock();
157 }
158
159 if message.metadata.contains_key(ACK_FOR_KEY) {
160 if !sender_blocked {
161 self.handle_ack_message(&message);
162 }
163 continue;
164 }
165
166 if self.deduplicator.is_duplicate_mut(&message.id) {
167 // Re-ACK duplicate packets so the sender can stop
168 // retrying — but NOT for blocked users, to avoid
169 // leaking presence information.
170 //
171 // Every copy is answered, including the several that
172 // one delivery produces when it travels through nearby
173 // devices. Answering only the first was tried and
174 // rejected: a copy arriving moments later and a
175 // retransmission arriving because the answer was lost
176 // are not distinguishable here, and staying quiet for
177 // the second turns a delivered message into a failed
178 // one. The redundant answers are bounded — a delivery
179 // arrives by at most as many paths as neighbors carried
180 // it, answers never provoke further answers, and every
181 // frame that leaves is rate-capped like any other.
182 if !sender_blocked && message.requires_ack {
183 if let Err(err) = self.send_delivery_ack(&message, transport_used) {
184 error!(
185 message_id = %message.id,
186 error = %err,
187 "Failed to send delivery ACK for duplicate message"
188 );
189 }
190 }
191 continue;
192 }
193
194 // Block filter: silently drop messages from blocked users
195 // addressed to us. No ACK, no event, no side-effects.
196 // Checked before mark_seen so that if the user is later
197 // unblocked, retransmissions can still be delivered.
198 if sender_blocked {
199 debug!(
200 sender = %message.sender,
201 message_id = %message.id,
202 "Dropping message from blocked user"
203 );
204 continue;
205 }
206
207 self.deduplicator.mark_seen(message.id.clone());
208
209 // Handle internal MLS messages
210 let mut was_decrypted = false;
211 if let Some(result) =
212 self.process_internal_message_via(&message, Some(transport_used))
213 {
214 match result {
215 InternalMessageResult::Consumed => {
216 // Internal control messages are still delivery-sensitive for
217 // the sender (invites/accept/welcome). ACK before consume.
218 if message.requires_ack {
219 if let Err(err) =
220 self.send_delivery_ack(&message, transport_used)
221 {
222 error!(
223 message_id = %message.id,
224 error = %err,
225 "Failed to send delivery ACK for internal message"
226 );
227 }
228 }
229 // Internal message handled, don't surface to app
230 continue;
231 }
232 InternalMessageResult::SecurityRejected => {
233 // Security gate rejected this message (spoofed sender,
234 // bad signature, unsigned control traffic, or a signing
235 // key that does not derive to the claimed sender
236 // address). Do NOT send a delivery
237 // ACK — acknowledging would confirm to the attacker that
238 // the target peer is online and processing messages.
239 // Forget the id too, or an exact replay would hit the
240 // duplicate re-ACK path above and leak that presence
241 // anyway; reprocessing a replay costs no more than a
242 // fresh forged message.
243 self.deduplicator.unmark_seen(&message.id);
244 continue;
245 }
246 InternalMessageResult::Deferred => {
247 // The message was not delivered, but the sender
248 // can still recover it by resending: it could not
249 // be decrypted *yet* (session not ready, so queued
250 // for delayed decryption), or it is undecryptable
251 // as it stands (epoch desync, or a hard crypto
252 // failure while `crypto_recovery_enabled`) and was
253 // dropped without queueing. See the three
254 // conditions on `InternalMessageResult::Deferred`.
255 //
256 // Do NOT send a delivery ACK and do NOT keep the id
257 // dedup-marked: the message is not delivered, so the
258 // sender must be free to retry and that retry must
259 // re-enter processing rather than hit the duplicate
260 // re-ACK path above. For a queued copy, the session
261 // confirming drains it: the message is surfaced and
262 // the id re-marked (see `process_pending_decryption`),
263 // so the sender's next resend is then deduped +
264 // re-ACKed. For the other two, recovery is that
265 // resend itself — re-sealed against a live
266 // generation by Tier 2.
267 self.deduplicator.unmark_seen(&message.id);
268 continue;
269 }
270 InternalMessageResult::Decrypted(plaintext) => {
271 was_decrypted = true;
272 Self::apply_decrypted_content(&mut message, plaintext);
273 }
274 }
275 }
276
277 // Policy gate for inbound plaintext (SEC: inbound text must
278 // honor require_encryption exactly like legacy media does).
279 // A message that reaches this point without decryption is
280 // unauthenticated cleartext with an attacker-controllable
281 // sender. FileChunk messages are exempt: encrypted media
282 // envelopes carry no content prefix (the ciphertext rides
283 // in binary_content), so they are indistinguishable from
284 // plaintext here — handle_incoming_file_chunk applies this
285 // same gate after telling the two apart. Rejection sends
286 // no delivery ACK, mirroring SecurityRejected: don't
287 // confirm to an injector that the target processes their
288 // messages. The id is forgotten so a replay re-enters
289 // this gate instead of the duplicate re-ACK path.
290 if !was_decrypted
291 && message.content_type != ContentType::FileChunk
292 && !self.accept_plaintext_content(message.sender.as_str())
293 {
294 warn!(
295 message_id = %message.id,
296 sender = %message.sender,
297 "Rejecting unencrypted inbound message (encryption policy)"
298 );
299 self.warn_plaintext_receive_rejected(
300 message.sender.as_str(),
301 "Inbound plaintext message rejected by encryption policy",
302 );
303 self.deduplicator.unmark_seen(&message.id);
304 continue;
305 }
306
307 // Route file-chunk messages to the transfer manager BEFORE
308 // the delivery ACK, and never surface them to the app as
309 // regular messages. An encrypted chunk that was not
310 // delivered but that the sender can still recover by
311 // resending — not decryptable yet (session not ready, so
312 // queued for delayed decryption), or undecryptable as it
313 // stands (epoch desync, crypto failure) — returns
314 // `Deferred`: it must NOT be ACKed and its id is unmarked,
315 // so the sender keeps retrying and the resend re-enters
316 // processing, matching the text `Deferred` path. Every other
317 // outcome (`Handled`: decrypted/assembled, or a terminal
318 // drop) is ACKed as before, since the sender cannot recover
319 // it by retrying.
320 if message.content_type == ContentType::FileChunk {
321 match self.handle_incoming_file_chunk_via(&message, Some(transport_used)) {
322 ChunkOutcome::Deferred => {
323 self.deduplicator.unmark_seen(&message.id);
324 }
325 // Plaintext chunk rejected by encryption policy:
326 // withhold the ACK and unmark the id, exactly like
327 // the plaintext-text rejection above and the text
328 // `SecurityRejected` path — don't confirm to an
329 // injector that we process their messages, and let a
330 // replay re-enter the gate rather than hit the
331 // duplicate re-ACK path.
332 ChunkOutcome::Rejected => {
333 self.deduplicator.unmark_seen(&message.id);
334 }
335 ChunkOutcome::Handled => {
336 if message.requires_ack {
337 if let Err(err) =
338 self.send_delivery_ack(&message, transport_used)
339 {
340 error!(
341 message_id = %message.id,
342 error = %err,
343 "Failed to send delivery ACK for media chunk"
344 );
345 }
346 }
347 }
348 }
349 continue;
350 }
351
352 if message.requires_ack {
353 if let Err(err) = self.send_delivery_ack(&message, transport_used) {
354 error!(
355 message_id = %message.id,
356 error = %err,
357 "Failed to send delivery ACK"
358 );
359 }
360 }
361
362 let forward_info = message
363 .forwarded_from
364 .as_ref()
365 .map(crate::events::ForwardInfoEvent::from);
366
367 let event = Event::MessageReceived {
368 message_id: message.id.as_str(),
369 sender: message.sender.as_str().to_string(),
370 recipient: message.recipient.as_str().to_string(),
371 content: message.content.clone(),
372 hop_count: message.hop_count.value(),
373 transport: transport_used.to_string(),
374 timestamp: message.timestamp.as_millis(),
375 lamport_clock: message.lamport_clock.value(),
376 reply_to_msg: message
377 .reply_to_msg
378 .as_ref()
379 .map(|id| id.as_str().to_string()),
380 reply_context: message
381 .reply_context
382 .as_ref()
383 .map(|rc| Box::new(crate::events::ReplyContextEvent::from(rc))),
384 content_type: message.content_type.to_string(),
385 media_metadata: message.media_metadata.clone(),
386 forward_info,
387 encrypted: was_decrypted,
388 };
389
390 let Ok(state) = lock_shared_state(&self.shared_state) else {
391 error!("Failed to lock shared state for message received event");
392 return None;
393 };
394 state.emit_event(event);
395 drop(state);
396
397 return Some(message);
398 }
399 Ok(None) => return None,
400 Err(err) => {
401 error!(error = %err, "Transport receive error");
402 return None;
403 }
404 }
405 }
406 }
407
408 /// Considers an inbound frame addressed to a third party for forwarding.
409 ///
410 /// Learns the route back toward the sender, then offers the frame to the
411 /// governor, which decides whether it travels any further. Nothing is
412 /// transmitted here: an accepted frame is queued and goes out from
413 /// [`Self::flush_mesh_relays`] once its delay elapses, which is what gives
414 /// a neighbor the chance to cover it first.
415 ///
416 /// `arrival_peer` is the neighbor that handed us this frame, when the
417 /// carrier identified the link.
418 fn try_relay_message(&mut self, message: &Message, arrival_peer: Option<&str>) {
419 // Cheapest answer first. This device sees the whole neighborhood's
420 // traffic, so in a crowded room most third-party arrivals are copies of
421 // a frame it has already dealt with — and everything below would be
422 // thrown away by the suppression check inside `admit`: a route-table
423 // write, a battery snapshot that locks and allocates across every
424 // transport, and an enumeration of every link. Answering a duplicate
425 // costs a hash lookup instead.
426 //
427 // Route learning is skipped along with the rest, which is deliberate:
428 // the copy teaches nothing the first one did not, and the table is not
429 // what forwarding decisions are made from. A duplicate we still hold a
430 // pending copy of is *not* absorbed here — standing down for a neighbor
431 // needs the neighbor set — so that case still takes the full path.
432 if self
433 .mesh_relay
434 .absorb_settled_duplicate(&message.id.as_str())
435 {
436 return;
437 }
438
439 // Learn the route back toward the sender. The next hop is the neighbor
440 // that handed us the frame, not the sender itself: for anything past
441 // the first hop the sender is several links away and naming it here
442 // would record a next hop we cannot address. Frames from carriers that
443 // do not identify links fall back to the sender, which is correct at
444 // one hop and no worse than before beyond it.
445 let learned_next_hop = arrival_peer.unwrap_or_else(|| message.sender.as_str());
446 self.path_selector.learn_route_from_message(
447 message,
448 learned_next_hop,
449 RELAY_LEARNED_ROUTE_QUALITY,
450 );
451
452 // Carrying traffic for others costs this device's radio and battery,
453 // so it stays a local decision.
454 let relay_allowed = self.config.relay.allow_relay
455 && self.config.relay.relay_priority != RelayPriority::Never;
456
457 if !relay_allowed {
458 debug!(
459 message_id = %message.id,
460 "Not forwarding: relaying is disabled on this device"
461 );
462 return;
463 }
464
465 // Carrying other people's messages is the first thing to give up when
466 // the battery is going: a device that spends its last few percent
467 // relaying cannot send its own message when its owner needs to. The
468 // device keeps sending and receiving its own traffic either way.
469 //
470 // An unknown battery level is treated as willing — most platforms
471 // report one, and refusing to carry anything on a device that simply
472 // does not publish a level would quietly remove it from the network.
473 if !self.battery_allows_relaying() {
474 debug!(
475 message_id = %message.id,
476 "Not forwarding: battery is below the level for carrying traffic"
477 );
478 return;
479 }
480
481 let neighbors = self.transport_manager.mesh_neighbors();
482 let degree = neighbors.len();
483 // Whether we hold the link to the recipient ourselves. At the last hop
484 // no other device can be assumed to have it, so our copy must not be
485 // dropped in favour of a neighbor's.
486 let is_last_hop = neighbors
487 .iter()
488 .any(|n| n.peer_id == message.recipient.as_str());
489 match self
490 .mesh_relay
491 .admit(message, arrival_peer, degree, is_last_hop)
492 {
493 MeshRelayAdmission::Queued => {
494 debug!(
495 message_id = %message.id,
496 recipient = %message.recipient,
497 degree,
498 "Queued frame for forwarding"
499 );
500 }
501 MeshRelayAdmission::Rejected(reason) => {
502 debug!(
503 message_id = %message.id,
504 ?reason,
505 "Not forwarding"
506 );
507 }
508 }
509 }
510
511 /// Whether the battery is healthy enough to carry other people's traffic.
512 ///
513 /// Applies [`RelayConfig::min_battery_for_relay`], the same floor the relay
514 /// role uses, with the same exemption for a charging device: plugged in,
515 /// only a critical level stops it. An unknown level means yes — see the
516 /// caller.
517 fn battery_allows_relaying(&self) -> bool {
518 let (_statuses, available) = self.transport_manager.snapshot_status_and_available();
519 let (battery_level, is_charging) =
520 crate::telemetry::aggregator::device_battery_from_available(
521 self.transport_manager.current_transport(),
522 &available,
523 );
524
525 let Some(level) = battery_level else {
526 return true;
527 };
528
529 if level < CRITICAL_RELAY_BATTERY_LEVEL {
530 return false;
531 }
532
533 is_charging || level >= self.config.relay.min_battery_for_relay
534 }
535
536 /// Transmits the forwards whose delay has elapsed.
537 ///
538 /// Called from the process tick. Each frame goes to a bounded set of
539 /// neighbors chosen by the governor, never back to the peer it came from or
540 /// to the peer that wrote it. A frame whose recipient is a neighbor of ours
541 /// is handed straight to them instead — the shortest path we can see.
542 pub(super) fn flush_mesh_relays(&mut self) {
543 let due = self.mesh_relay.take_due(Instant::now());
544 if due.is_empty() {
545 return;
546 }
547
548 let neighbors = self.transport_manager.mesh_neighbors();
549
550 // `relay` is borrowed rather than destructured throughout: a frame that
551 // reaches no neighbor has to be handed back whole, and cloning the
552 // message to keep that option open would copy the payload — up to a
553 // whole media chunk — on every flush.
554 for relay in due {
555 let message = &relay.message;
556 let recipient = message.recipient.as_str().to_string();
557 let message_id = message.id.as_str();
558 let hop_count = message.hop_count.value();
559 let remaining_ttl = message.ttl.value();
560
561 let mut exclude: Vec<&str> = vec![message.sender.as_str()];
562 if let Some(peer) = relay.arrival_peer.as_deref() {
563 exclude.push(peer);
564 }
565 let onward = self.mesh_relay.select_targets(
566 neighbors
567 .iter()
568 .map(|n| (n.peer_id.as_str(), n.link_quality())),
569 &exclude,
570 &message_id,
571 );
572
573 // If the destination is one of our own neighbors, hand it over
574 // directly: no fan-out is worth more than arriving. Should that
575 // link fail between choosing it and writing to it, fall back to
576 // carrying it onward rather than dropping a frame we could still
577 // move.
578 let targets = if neighbors.iter().any(|n| n.peer_id == recipient) {
579 let mut ordered = vec![recipient.clone()];
580 ordered.extend(onward.into_iter().filter(|peer| peer != &recipient));
581 ordered
582 } else {
583 onward
584 };
585 let deliver_direct = targets.first() == Some(&recipient);
586
587 if targets.is_empty() {
588 // Nowhere to hand it right now: we hold no link, or the only
589 // ones we hold are the peers this frame must not go back to.
590 // Falls through to the hand-back below rather than being
591 // dropped — a neighbor may well appear within the few seconds
592 // the frame is still worth carrying.
593 debug!(
594 message_id = %message.id,
595 "No onward neighbor for this frame"
596 );
597 }
598
599 let mut delivered_to = 0usize;
600 for target in &targets {
601 // Each link this frame crosses is one transmission against the
602 // device's ceiling. Running out mid-fan-out stops the fan-out
603 // rather than the frame: the neighbors already reached carry it
604 // on, and the sender's retry covers the rest.
605 if !self.mesh_relay.take_send_token() {
606 debug!(
607 message_id = %message.id,
608 "At the forwarding limit; stopping this fan-out here"
609 );
610 break;
611 }
612
613 match self.transport_manager.send_to_neighbor(target, message) {
614 Ok(transport) => {
615 delivered_to += 1;
616 // Handing it to the recipient themselves ends the
617 // journey; the remaining neighbors are only a fallback
618 // for that link failing.
619 if deliver_direct && target == &recipient {
620 debug!(
621 message_id = %message.id,
622 next_hop = %target,
623 transport = ?transport,
624 "Delivered to its recipient directly"
625 );
626 break;
627 }
628 debug!(
629 message_id = %message.id,
630 next_hop = %target,
631 transport = ?transport,
632 hop_count,
633 remaining_ttl,
634 "Forwarded frame to neighbor"
635 );
636 }
637 Err(err) => {
638 // A link that went away between selection and send.
639 // The other targets still carry the frame.
640 debug!(
641 message_id = %message.id,
642 next_hop = %target,
643 error = %err,
644 "Could not forward to neighbor"
645 );
646 }
647 }
648 }
649
650 // Nothing reached a neighbor. Hand it back instead of dropping it:
651 // its id is already recorded as handled here, so a drop would lose
652 // this copy *and* refuse both the copies arriving behind it and the
653 // sender's retransmissions, for the whole retention window.
654 //
655 // Why it got nowhere does not change that. The budget ran out (the
656 // frames released alongside this one spent the allowance between
657 // them), every link chosen for it went away between being picked
658 // and being written to, or there was no usable link to pick. In all
659 // three the frame has travelled nowhere and this queue is the only
660 // thing that still remembers it. It keeps its due time, so one that
661 // stays stuck is abandoned by the overdue cut-off rather than
662 // retried forever.
663 if delivered_to == 0 {
664 self.mesh_relay.requeue(relay);
665 continue;
666 }
667
668 self.mesh_relay.record_forwarded();
669 self.emit_event(Event::message_relayed(
670 message.id.as_str(),
671 message.sender.as_str().to_string(),
672 recipient,
673 hop_count,
674 remaining_ttl,
675 ));
676 }
677 }
678
679 /// [`Self::handle_incoming_file_chunk_via`] with no known arrival transport
680 /// (a deferred chunk falls back to the resend-driven ACK). Used only by
681 /// tests; production always routes through the `_via` form with the inbound
682 /// transport.
683 #[cfg(test)]
684 pub(super) fn handle_incoming_file_chunk(&mut self, message: &Message) -> ChunkOutcome {
685 self.handle_incoming_file_chunk_via(message, None)
686 }
687
688 /// Routes an inbound file-chunk message through the transfer manager,
689 /// recording `arrival_transport` on the pending entry if the chunk must be
690 /// deferred, so the drain can ACK it directly once the session confirms.
691 pub(super) fn handle_incoming_file_chunk_via(
692 &mut self,
693 message: &Message,
694 arrival_transport: Option<TransportType>,
695 ) -> ChunkOutcome {
696 let sender = message.sender.as_str().to_string();
697
698 // SEC-H1: encrypted chunks carry the chunk bytes, media metadata, and
699 // original content type inside the MLS ciphertext; legacy plaintext
700 // chunks carry them on the wire Message and are accepted only where
701 // policy allows. Rich extras (caption, reply, forward) only ever come
702 // from the sealed plaintext — never from the wire Message.
703 let (chunk, media_metadata, original_content_type, rich_extras) = if let Some(ref binary) =
704 message.binary_content
705 {
706 if is_media_envelope(binary) {
707 let encrypted = match decode_media_envelope(binary) {
708 Ok(e) => e,
709 // A frame whose magic byte still reads as a media envelope
710 // but whose encoding does not decode — in-transit
711 // corruption past the magic, or injected garbage. Under
712 // crypto recovery this is the pre-decrypt sibling of a hard
713 // decrypt failure: the frame as it stands is dead, but the
714 // sender's resend carries a fresh encoding that would
715 // decode, so withhold the ACK (`Deferred`) instead of
716 // telling the sender "delivered" for a chunk we dropped.
717 // Do NOT enqueue: an unparseable frame can never become
718 // parseable, so a queued copy could never drain — the same
719 // reasoning as the spent-generation arm below.
720 Err(e) => {
721 warn!(
722 message_id = %message.id,
723 error = %e,
724 "Failed to decode encrypted media envelope"
725 );
726 if !self.config.encryption.crypto_recovery_enabled {
727 return ChunkOutcome::Handled;
728 }
729 // Advisory, not terminal — the chunk was never ACKed, so
730 // the transfer is *stalled* pending a resend rather than
731 // failed. The terminal media signal stays
732 // `FileReceiveFailed`.
733 if let Ok(state) = lock_shared_state(&self.shared_state) {
734 state.emit_event(Event::message_decryption_failed(
735 message.id.clone(),
736 sender.clone(),
737 crate::events::DecryptionFailureCode::InvalidPayload,
738 "encrypted media envelope failed to decode; its file transfer is stalled until the sender resends".to_string(),
739 ));
740 }
741 return ChunkOutcome::Deferred;
742 }
743 };
744 let plaintext =
745 match self.decrypt_media_chunk(&sender, &encrypted, message, arrival_transport)
746 {
747 MediaChunkDecrypt::Plaintext(p) => p,
748 // Not delivered and recoverable by a resend (queued for
749 // delayed decryption, or an undecryptable frame the
750 // sender can re-send): defer the ACK so the sender
751 // retries and the resend re-enters processing.
752 MediaChunkDecrypt::Deferred => return ChunkOutcome::Deferred,
753 // Illegitimate frame (foreign session slot, or an MLS
754 // credential naming a different sender): answer with
755 // silence, exactly like the text `SecurityRejected`
756 // path. ACKing here would tell an injector that the
757 // target is online and processing their frames — the
758 // one thing the text path withholds.
759 MediaChunkDecrypt::SecurityRejected => return ChunkOutcome::Rejected,
760 // Terminal drop (permanent refusal, empty plaintext, MLS
761 // unavailable): ACK as before — the sender cannot
762 // recover it by retrying.
763 MediaChunkDecrypt::Dropped => return ChunkOutcome::Handled,
764 };
765 let inner = match MediaChunkPlaintext::decode(&plaintext) {
766 Ok(i) => i,
767 Err(e) => {
768 warn!(
769 message_id = %message.id,
770 error = %e,
771 "Failed to parse decrypted media chunk plaintext, dropping"
772 );
773 return ChunkOutcome::Handled;
774 }
775 };
776 let chunk = match FileChunk::from_bytes(&inner.chunk_bytes) {
777 Ok(c) => c,
778 Err(e) => {
779 warn!(
780 message_id = %message.id,
781 error = %e,
782 "Failed to deserialize decrypted file chunk, dropping"
783 );
784 return ChunkOutcome::Handled;
785 }
786 };
787 (
788 chunk,
789 inner.media_metadata,
790 inner.original_content_type,
791 inner.rich_extras,
792 )
793 } else {
794 if !self.accept_plaintext_content(&sender) {
795 warn!(
796 message_id = %message.id,
797 sender = %sender,
798 "Rejecting unencrypted media chunk (encryption policy)"
799 );
800 self.warn_plaintext_receive_rejected(
801 &sender,
802 "Inbound plaintext media chunk rejected by encryption policy",
803 );
804 return ChunkOutcome::Rejected;
805 }
806 let chunk = match FileChunk::from_bytes(binary) {
807 Ok(c) => c,
808 Err(e) => {
809 warn!(
810 message_id = %message.id,
811 error = %e,
812 "Failed to deserialize binary file chunk, dropping"
813 );
814 return ChunkOutcome::Handled;
815 }
816 };
817 let (meta, oct) = Self::wire_media_metadata(message);
818 (chunk, meta, oct, None)
819 }
820 } else {
821 if !self.accept_plaintext_content(&sender) {
822 warn!(
823 message_id = %message.id,
824 sender = %sender,
825 "Rejecting unencrypted media chunk (encryption policy)"
826 );
827 self.warn_plaintext_receive_rejected(
828 &sender,
829 "Inbound plaintext media chunk rejected by encryption policy",
830 );
831 return ChunkOutcome::Rejected;
832 }
833 let chunk = match FileChunk::from_json(&message.content) {
834 Ok(c) => c,
835 Err(e) => {
836 warn!(
837 message_id = %message.id,
838 error = %e,
839 "Failed to deserialize file chunk, dropping"
840 );
841 return ChunkOutcome::Handled;
842 }
843 };
844 let (meta, oct) = Self::wire_media_metadata(message);
845 (chunk, meta, oct, None)
846 };
847
848 let file_id = chunk.file_id.clone();
849 let file_name = chunk.file_name.clone();
850 let file_size = chunk.file_size;
851 let is_first_chunk = chunk.chunk_index == 0;
852
853 // Metadata is recorded only for chunks the manager accepts — a
854 // rejected chunk must leave no state behind (SEC-H2).
855 match self.file_transfer_manager.process_chunk(&sender, chunk) {
856 Ok(progress) => {
857 if is_first_chunk {
858 self.pending_media_metadata.insert(
859 file_id.clone(),
860 PendingMediaMetadataEntry {
861 content_type: original_content_type.unwrap_or(ContentType::File),
862 media_metadata,
863 last_updated_at: Instant::now(),
864 sender: sender.clone(),
865 rich_extras,
866 timestamp_ms: message.timestamp.as_millis(),
867 },
868 );
869 } else if let Some(entry) = self.pending_media_metadata.get_mut(&file_id) {
870 entry.last_updated_at = Instant::now();
871 }
872 if let Ok(state) = lock_shared_state(&self.shared_state) {
873 state.emit_event(Event::file_progress(
874 file_id.clone(),
875 progress.chunks_completed,
876 progress.total_chunks,
877 ));
878 }
879 }
880 Err(rejection) if rejection.is_resource_exhaustion() => {
881 // A well-formed transfer was dropped by a receiver-side
882 // resource limit. The chunk was already ACKed and will not
883 // be retransmitted, so the transfer is unrecoverable —
884 // surface that to the application instead of going silent.
885 self.pending_media_metadata.remove(&file_id);
886 if let Ok(state) = lock_shared_state(&self.shared_state) {
887 state.emit_event(Event::file_receive_failed(
888 file_id,
889 file_name,
890 sender,
891 rejection.as_str().to_string(),
892 ));
893 }
894 return ChunkOutcome::Handled;
895 }
896 // Malformed or mismatched chunks are logged by the manager; the
897 // assembly they targeted (if any) stays intact. Chunks of an
898 // already-failed transfer land here too (`previously_failed`) —
899 // its FileReceiveFailed event already fired exactly once.
900 Err(_) => return ChunkOutcome::Handled,
901 }
902
903 if self.file_transfer_manager.is_complete(&file_id) {
904 let Some(file_data) = self.file_transfer_manager.finalize_file(&file_id) else {
905 warn!(
906 file_id = %file_id,
907 "File transfer marked complete but reassembly or integrity checks failed"
908 );
909 self.pending_media_metadata.remove(&file_id);
910 if let Ok(state) = lock_shared_state(&self.shared_state) {
911 state.emit_event(Event::file_receive_failed(
912 file_id,
913 file_name,
914 sender,
915 "integrity_check_failed".to_string(),
916 ));
917 }
918 return ChunkOutcome::Handled;
919 };
920 let metadata_entry = self.pending_media_metadata.remove(&file_id);
921 let (content_type, media_metadata, rich_extras, timestamp_ms) = metadata_entry
922 .map(|entry| {
923 (
924 entry.content_type,
925 entry.media_metadata,
926 entry.rich_extras,
927 Some(entry.timestamp_ms),
928 )
929 })
930 .unwrap_or((ContentType::File, None, None, None));
931 let rich_extras = rich_extras.unwrap_or_default();
932
933 if let Ok(state) = lock_shared_state(&self.shared_state) {
934 state.emit_event(Event::file_received(
935 file_id,
936 file_name,
937 file_size,
938 sender,
939 content_type,
940 media_metadata,
941 file_data,
942 timestamp_ms,
943 rich_extras.caption,
944 rich_extras.reply_to_msg,
945 rich_extras.reply_context.as_ref(),
946 rich_extras.forward_info.as_ref(),
947 ));
948 }
949 }
950
951 ChunkOutcome::Handled
952 }
953
954 /// Extracts the chunk-0 metadata a legacy (unencrypted) chunk carries on
955 /// the wire `Message`.
956 fn wire_media_metadata(message: &Message) -> (Option<MediaMetadata>, Option<ContentType>) {
957 use crate::constants::ORIGINAL_CONTENT_TYPE_KEY;
958 let original_ct = message
959 .metadata
960 .get(ORIGINAL_CONTENT_TYPE_KEY)
961 .map(|s| ContentType::parse(s));
962 (message.media_metadata.clone(), original_ct)
963 }
964
965 /// Policy gate for inbound plaintext content — text messages and legacy
966 /// (unencrypted) media chunks alike: rejected when this node requires
967 /// encryption, and rejected once the sender is known to run MLS — an
968 /// encryption-capable peer sending plaintext is a downgrade/forgery attempt
969 /// (plaintext carries no sender authentication, so anyone could inject it
970 /// under a contact's name).
971 ///
972 /// # Capability, not confirmation
973 ///
974 /// This asks whether the peer *can* encrypt, not whether a session is
975 /// confirmed. The distinction is the whole gate: a sender only ever emits
976 /// plaintext when its own `should_auto_encrypt()` is false, which precludes
977 /// it having established a session with us — while the session is merely
978 /// pending it *queues* (`prepare_outbound_content`) rather than downgrading.
979 /// So no honest peer sends cleartext while we know it speaks MLS, and
980 /// asking only about *confirmed* sessions left a hole: an attacker with
981 /// app-container write access deletes the `session_states` record, restore
982 /// re-bootstraps it as `Pending` (which it must), the peer silently drops
983 /// out of `confirmed_sessions`, and this gate re-opens. See
984 /// [`OfflineProtocol::encryption_capable_peers`] for why that signal now
985 /// comes from the credential store instead.
986 ///
987 /// The capability check is also *cheaper* than the confirmation one it
988 /// precedes: it is an in-memory set lookup, where `is_session_confirmed`
989 /// falls through to a protocol-state read plus a sealed-record open for any
990 /// sender not already in `confirmed_sessions` — a path driven by the
991 /// attacker-controlled `message.sender`. Ordering the cheap check first
992 /// makes a flood of forged sender ids cost less than it used to.
993 ///
994 /// The group path has gated on existence rather than confirmation since it
995 /// was written (`has_mls_group_state`); this brings the 1:1 path into line.
996 fn accept_plaintext_content(&mut self, sender: &str) -> bool {
997 if self.config.encryption.require_encryption {
998 return false;
999 }
1000 // `should_auto_encrypt()` guards both checks: a node with encryption
1001 // disabled or MLS uninitialized has no standing to call anything a
1002 // downgrade, and removing this guard would reject legitimate plaintext
1003 // on every plaintext-only deployment.
1004 if !self.should_auto_encrypt() {
1005 return true;
1006 }
1007 if self.is_encryption_capable(sender) {
1008 return false;
1009 }
1010 // Fail closed: if the confirmation lookup errors (storage failure),
1011 // treat the session as confirmed and reject — accepting plaintext on
1012 // error would let a storage fault disable the downgrade gate.
1013 if self.is_session_confirmed(sender).unwrap_or(true) {
1014 return false;
1015 }
1016 true
1017 }
1018
1019 /// Decrypts an encrypted media chunk envelope. Four failure dispositions,
1020 /// mirroring the text path in [`Self::handle_encrypted_message`]:
1021 ///
1022 /// - **Session not ready**: the whole message is queued for delayed
1023 /// decryption and [`MediaChunkDecrypt::Deferred`] is returned.
1024 /// - **Recoverable** (epoch desync, or a crypto/transport failure while
1025 /// `crypto_recovery_enabled`): [`MediaChunkDecrypt::Deferred`] *without*
1026 /// queueing — the ciphertext is dead either way, so recovery is the
1027 /// sender's resend, driven by the withheld ACK.
1028 /// - **Security rejection** (the envelope names another pair's session
1029 /// slot, or the MLS credential authenticates a different sender than the
1030 /// wire envelope claims): [`MediaChunkDecrypt::SecurityRejected`], which
1031 /// the caller answers with silence. Deliberately *not* gated on
1032 /// `crypto_recovery_enabled` — this is about what the receiver reveals,
1033 /// not about recovery, and the text path's equivalent is unconditional.
1034 /// - **Terminal** (a permanent refusal, an empty plaintext, or any crypto
1035 /// failure with recovery switched off): decryption telemetry plus
1036 /// [`MediaChunkDecrypt::Dropped`], which the caller still ACKs.
1037 ///
1038 /// On `Deferred` or `SecurityRejected` the caller must skip the ACK and
1039 /// unmark the id — for the former so the sender keeps retrying, for the
1040 /// latter so a replay re-enters this gate rather than hitting the duplicate
1041 /// re-ACK path. A successful decrypt doubles as a session confirmation
1042 /// signal, exactly like text decrypts.
1043 fn decrypt_media_chunk(
1044 &mut self,
1045 sender: &str,
1046 encrypted: &EncryptedMessage,
1047 message: &Message,
1048 arrival_transport: Option<TransportType>,
1049 ) -> MediaChunkDecrypt {
1050 let group_id = encrypted.group_id.as_str().to_string();
1051
1052 // Media envelopes are only ever produced for the sender's 1:1 session,
1053 // whose MLS group id is deterministic. Enforce that binding before
1054 // decrypting: MLS authenticates group membership, not the wire sender
1055 // claim, so without this check any peer holding a valid session with
1056 // us could deliver its own ciphertext under an arbitrary
1057 // `message.sender` and have the file attributed to that identity.
1058 let Ok(expected_group) = GroupId::for_session(&self.local_id, sender) else {
1059 warn!(
1060 sender = %sender,
1061 "Cannot derive 1:1 session id for media chunk sender, dropping"
1062 );
1063 return MediaChunkDecrypt::Dropped;
1064 };
1065 if encrypted.group_id != expected_group {
1066 error!(
1067 sender = %sender,
1068 group_id = %group_id,
1069 expected = %expected_group,
1070 "SECURITY: encrypted media chunk MLS group does not match the claimed sender's session, rejecting"
1071 );
1072 self.emit_security_warning(
1073 sender,
1074 SecurityWarningCode::MediaSenderGroupMismatch,
1075 "Encrypted media chunk MLS group does not match the claimed sender",
1076 );
1077 // Not ACKed: the text path answers the identical condition
1078 // (`MlsError::SessionIdentityMismatch` →
1079 // `InternalMessageResult::SecurityRejected`) with silence, and an
1080 // ACK here would leak exactly what that silence protects — an
1081 // injector who gets an ACK for a media chunk but nothing for the
1082 // same text frame learns the target is live either way.
1083 return MediaChunkDecrypt::SecurityRejected;
1084 }
1085
1086 let Some(mls) = self.mls_manager.clone() else {
1087 warn!(
1088 sender = %sender,
1089 "Encrypted media chunk received but MLS is not initialized, dropping"
1090 );
1091 self.emit_mls_decryption_failed(
1092 sender,
1093 Some(&group_id),
1094 DecryptionFailureKind::NotInitialized,
1095 MlsOperationContext::Receive,
1096 );
1097 return MediaChunkDecrypt::Dropped;
1098 };
1099
1100 let decrypt_result = {
1101 let manager = match mls.read() {
1102 Ok(m) => m,
1103 Err(_) => {
1104 error!("MLS lock poisoned while decrypting media chunk");
1105 return MediaChunkDecrypt::Dropped;
1106 }
1107 };
1108 manager.decrypt(encrypted, sender)
1109 };
1110
1111 match decrypt_result {
1112 Ok(Some(plaintext)) => {
1113 // A group-aware decrypt is the same confirmation signal the
1114 // text path uses; skip when already cache-confirmed to avoid
1115 // per-chunk storage I/O.
1116 if !self.confirmed_sessions.contains(sender) {
1117 self.confirm_session_from_successful_decrypt(sender, &group_id);
1118 }
1119 MediaChunkDecrypt::Plaintext(plaintext)
1120 }
1121 Ok(None) => {
1122 warn!(sender = %sender, "Media chunk decryption returned empty, dropping");
1123 MediaChunkDecrypt::Dropped
1124 }
1125 // Intercepted BEFORE classification, mirroring the text path in
1126 // `handle_encrypted_message`. Both variants classify as
1127 // `SessionStateError::Unknown`, whose disposition must stay
1128 // terminal-drop-and-ACK for the refusals that genuinely belong
1129 // there (`CommitNotAuthorized`), so the split has to happen here
1130 // rather than in the classifier.
1131 Err(ref e) if is_media_security_rejection(e) => {
1132 error!(
1133 sender = %sender,
1134 error = %e,
1135 "SECURITY: encrypted media chunk failed its sender/session identity check, rejecting"
1136 );
1137 MediaChunkDecrypt::SecurityRejected
1138 }
1139 Err(e) => {
1140 let classification = SessionStateError::from(&e);
1141 match classification {
1142 SessionStateError::SessionNotReady | SessionStateError::GroupNotFound => {
1143 info!(
1144 sender = %sender,
1145 error_code = classification.code(),
1146 "Encrypted media chunk received before session ready, queuing"
1147 );
1148 self.emit_mls_session_missing(
1149 Some(sender),
1150 Some(&group_id),
1151 MlsOperationContext::SessionLookup,
1152 MlsErrorCategory::SessionStateMissing,
1153 );
1154 self.enqueue_pending_decryption_via(sender, message, arrival_transport);
1155 MediaChunkDecrypt::Deferred
1156 }
1157 SessionStateError::SessionDesync
1158 if self.config.encryption.crypto_recovery_enabled =>
1159 {
1160 // Epoch desync (see the text path in `handle_encrypted_message`):
1161 // heal the channel with a rate-limited re-key and return
1162 // Deferred so the chunk is not ACKed and its id is
1163 // unmarked. We do NOT enqueue — the chunk is sealed to the
1164 // dead epoch and can never decrypt after the re-key.
1165 //
1166 // Unlike a DM, an in-flight media chunk cannot be
1167 // re-sealed for recovery: Tier 2 reseal is a deliberate
1168 // no-op for media (the media outbox is not persisted and
1169 // chunks are re-encoded, not replayed). Withholding the
1170 // ACK here is what drives recovery: the sender's media
1171 // outbox keeps retrying and, once its retry/ACK-timeout
1172 // budget lapses, surfaces `MediaResendRequired`, and the
1173 // app re-supplies the bytes via `send_media_with` — which
1174 // re-encodes fresh chunks against the now-healed session.
1175 // So media recovers via descriptor-based resend, not reseal.
1176 info!(
1177 sender = %sender,
1178 error_code = classification.code(),
1179 "Encrypted media chunk failed to decrypt due to epoch desync, re-keying"
1180 );
1181 self.schedule_session_rekey(sender);
1182 MediaChunkDecrypt::Deferred
1183 }
1184 SessionStateError::TransportFailure | SessionStateError::CryptoFailure
1185 if self.config.encryption.crypto_recovery_enabled =>
1186 {
1187 // A genuine crypto/transport failure on a chunk (AEAD
1188 // failure, spent ratchet generation, malformed frame).
1189 // Mirrors the text path in `handle_encrypted_message`:
1190 // withhold the ACK and unmark the id rather than lying
1191 // "delivered" for a chunk we dropped. Do NOT enqueue —
1192 // the generation is spent, so a queued copy could never
1193 // drain — and do NOT re-key, which stays desync-only.
1194 //
1195 // Recovery differs from a DM: chunks have no Tier 2
1196 // re-seal (they are re-encoded, not replayed), so the
1197 // un-ACKed chunk drives the media outbox's retry/ACK
1198 // budget to lapse and surface `MediaResendRequired`,
1199 // and the app re-supplies the bytes via `send_media_with`
1200 // — re-encoded against a live generation. Same
1201 // descriptor-based recovery as the media desync arm.
1202 warn!(
1203 sender = %sender,
1204 error = %e,
1205 error_code = classification.code(),
1206 "Failed to decrypt media chunk; withholding ACK so the transfer can be resent"
1207 );
1208 let kind = DecryptionFailureKind::from_mls_error(&e);
1209 self.emit_mls_decryption_failed(
1210 sender,
1211 Some(&group_id),
1212 kind,
1213 MlsOperationContext::Receive,
1214 );
1215 // Advisory, not terminal — matching a pending-queue
1216 // eviction: the chunk was never ACKed, so the transfer
1217 // is *stalled* pending a resend, not failed. The
1218 // terminal media signal is still `FileReceiveFailed`.
1219 if let Ok(state) = lock_shared_state(&self.shared_state) {
1220 state.emit_event(Event::message_decryption_failed(
1221 message.id.clone(),
1222 sender.to_string(),
1223 Self::decryption_failure_code_from_kind(kind),
1224 format!(
1225 "encrypted media chunk failed to decrypt ({}); its file transfer is stalled until the sender resends",
1226 classification.code()
1227 ),
1228 ));
1229 }
1230 MediaChunkDecrypt::Deferred
1231 }
1232 _ => {
1233 warn!(
1234 sender = %sender,
1235 error = %e,
1236 error_code = classification.code(),
1237 "Failed to decrypt media chunk, dropping"
1238 );
1239 let kind = DecryptionFailureKind::from_mls_error(&e);
1240 self.emit_mls_decryption_failed(
1241 sender,
1242 Some(&group_id),
1243 kind,
1244 MlsOperationContext::Receive,
1245 );
1246 // Permanently undecryptable (or crypto recovery is
1247 // switched off): the chunk is ACKed and stays
1248 // dedup-marked, so this loss is terminal for the
1249 // transfer — surface it to the app, not just as MLS
1250 // telemetry.
1251 if let Ok(state) = lock_shared_state(&self.shared_state) {
1252 state.emit_event(Event::message_decryption_failed(
1253 message.id.clone(),
1254 sender.to_string(),
1255 Self::decryption_failure_code_from_kind(kind),
1256 format!(
1257 "encrypted media chunk failed to decrypt ({}); its file transfer cannot complete",
1258 classification.code()
1259 ),
1260 ));
1261 }
1262 MediaChunkDecrypt::Dropped
1263 }
1264 }
1265 }
1266 }
1267 }
1268}
1269
1270/// Four-way outcome of [`OfflineProtocol::decrypt_media_chunk`]: the plaintext,
1271/// a deferral (not delivered, but recoverable by the sender's resend — either
1272/// queued because the session is not ready, or dropped as undecryptable with
1273/// the ACK withheld), a security rejection (an illegitimate frame the caller
1274/// must answer with silence), or a terminal drop the caller ACKs.
1275enum MediaChunkDecrypt {
1276 Plaintext(Vec<u8>),
1277 Deferred,
1278 /// The frame is not a legitimate message from the claimed sender: its
1279 /// envelope named another pair's session slot, or the MLS credential
1280 /// authenticated a different member than the wire envelope claims. Mirrors
1281 /// the text path's [`InternalMessageResult::SecurityRejected`] — the caller
1282 /// must NOT ACK (an ACK confirms to an injector that the target is online
1283 /// and processing their frames, which is exactly what the text path refuses
1284 /// to reveal) and must unmark the id.
1285 ///
1286 /// [`InternalMessageResult::SecurityRejected`]: super::InternalMessageResult::SecurityRejected
1287 SecurityRejected,
1288 Dropped,
1289}
1290
1291/// Whether an MLS decrypt error is a *security* rejection rather than a session
1292/// state problem — the two classes for which the receiver must stay silent
1293/// instead of ACKing.
1294///
1295/// Used as the match guard that intercepts these variants *before*
1296/// [`SessionStateError`] classification, exactly as the text path does inline in
1297/// `handle_encrypted_message`. The intercept cannot be replaced by a
1298/// classification arm: both variants map to [`SessionStateError::Unknown`], and
1299/// `Unknown` must keep its terminal drop-and-ACK disposition for the classes
1300/// that genuinely belong there (notably `CommitNotAuthorized`, a permanent
1301/// refusal whose sender gains nothing from retrying).
1302///
1303/// Kept as a named predicate rather than an inline pattern so the classification
1304/// itself is testable: `SenderIdentityMismatch` is not reachable through any
1305/// SDK-built ciphertext (see
1306/// `test_media_security_rejection_classifies_both_identity_mismatches`).
1307pub(super) fn is_media_security_rejection(error: &offline_protocol_mls::MlsError) -> bool {
1308 matches!(
1309 error,
1310 offline_protocol_mls::MlsError::SenderIdentityMismatch { .. }
1311 | offline_protocol_mls::MlsError::SessionIdentityMismatch { .. }
1312 // The leaf-binding refusals join them for the same reason: an
1313 // `__MLS_ENC__` envelope naming a `group:` id reaches group decrypt
1314 // through this path, both classify as `Unknown`, and `Unknown` must
1315 // keep drop-and-ACK for `CommitNotAuthorized`.
1316 | offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1317 | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1318 )
1319}