Skip to main content

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    /// Takes its own battery reading; the per-tick caller already holds one and
514    /// uses [`Self::battery_allows_relaying_with`] instead.
515    fn battery_allows_relaying(&self) -> bool {
516        let (_statuses, available) = self.transport_manager.snapshot_status_and_available();
517        let (battery_level, is_charging) =
518            crate::telemetry::aggregator::device_battery_from_available(
519                self.transport_manager.current_transport(),
520                &available,
521            );
522        self.battery_allows_relaying_with(battery_level, is_charging)
523    }
524
525    /// [`Self::battery_allows_relaying`] against a reading the caller already
526    /// has.
527    ///
528    /// An unknown level means yes — see [`Self::try_relay_message`].
529    pub(super) fn battery_allows_relaying_with(
530        &self,
531        battery_level: Option<u8>,
532        is_charging: bool,
533    ) -> bool {
534        let Some(level) = battery_level else {
535            return true;
536        };
537        level >= self.relay_battery_floor(is_charging)
538    }
539
540    /// The battery level below which this device stops carrying other people's
541    /// traffic.
542    ///
543    /// [`RelayConfig::min_battery_for_relay`] is the floor in the ordinary
544    /// case, relaxed to the hard [`CRITICAL_RELAY_BATTERY_LEVEL`] for a device
545    /// that is either charging or configured [`RelayPriority::Always`] — the
546    /// two ways of saying "keep relaying anyway". The hard floor is never
547    /// crossed: it applies even to a configured minimum set below it, because
548    /// spending the last few percent on strangers' traffic leaves a device
549    /// unable to send its own.
550    pub(super) fn relay_battery_floor(&self, is_charging: bool) -> u8 {
551        let eager = matches!(self.config.relay.relay_priority, RelayPriority::Always);
552        if is_charging || eager {
553            CRITICAL_RELAY_BATTERY_LEVEL
554        } else {
555            self.config
556                .relay
557                .min_battery_for_relay
558                .max(CRITICAL_RELAY_BATTERY_LEVEL)
559        }
560    }
561
562    /// Transmits the forwards whose delay has elapsed.
563    ///
564    /// Called from the process tick. Each frame goes to a bounded set of
565    /// neighbors chosen by the governor, never back to the peer it came from or
566    /// to the peer that wrote it. A frame whose recipient is a neighbor of ours
567    /// is handed straight to them instead — the shortest path we can see.
568    pub(super) fn flush_mesh_relays(&mut self) {
569        let due = self.mesh_relay.take_due(Instant::now());
570        if due.is_empty() {
571            return;
572        }
573
574        let neighbors = self.transport_manager.mesh_neighbors();
575
576        // `relay` is borrowed rather than destructured throughout: a frame that
577        // reaches no neighbor has to be handed back whole, and cloning the
578        // message to keep that option open would copy the payload — up to a
579        // whole media chunk — on every flush.
580        for relay in due {
581            let message = &relay.message;
582            let recipient = message.recipient.as_str().to_string();
583            let message_id = message.id.as_str();
584            let hop_count = message.hop_count.value();
585            let remaining_ttl = message.ttl.value();
586
587            let mut exclude: Vec<&str> = vec![message.sender.as_str()];
588            if let Some(peer) = relay.arrival_peer.as_deref() {
589                exclude.push(peer);
590            }
591            let onward = self.mesh_relay.select_targets(
592                neighbors
593                    .iter()
594                    .map(|n| (n.peer_id.as_str(), n.link_quality())),
595                &exclude,
596                &message_id,
597            );
598
599            // If the destination is one of our own neighbors, hand it over
600            // directly: no fan-out is worth more than arriving. Should that
601            // link fail between choosing it and writing to it, fall back to
602            // carrying it onward rather than dropping a frame we could still
603            // move.
604            let targets = if neighbors.iter().any(|n| n.peer_id == recipient) {
605                let mut ordered = vec![recipient.clone()];
606                ordered.extend(onward.into_iter().filter(|peer| peer != &recipient));
607                ordered
608            } else {
609                onward
610            };
611            let deliver_direct = targets.first() == Some(&recipient);
612
613            if targets.is_empty() {
614                // Nowhere to hand it right now: we hold no link, or the only
615                // ones we hold are the peers this frame must not go back to.
616                // Falls through to the hand-back below rather than being
617                // dropped — a neighbor may well appear within the few seconds
618                // the frame is still worth carrying.
619                debug!(
620                    message_id = %message.id,
621                    "No onward neighbor for this frame"
622                );
623            }
624
625            let mut delivered_to = 0usize;
626            for target in &targets {
627                // Each link this frame crosses is one transmission against the
628                // device's ceiling. Running out mid-fan-out stops the fan-out
629                // rather than the frame: the neighbors already reached carry it
630                // on, and the sender's retry covers the rest.
631                if !self.mesh_relay.take_send_token() {
632                    debug!(
633                        message_id = %message.id,
634                        "At the forwarding limit; stopping this fan-out here"
635                    );
636                    break;
637                }
638
639                match self.transport_manager.send_to_neighbor(target, message) {
640                    Ok(transport) => {
641                        delivered_to += 1;
642                        // Handing it to the recipient themselves ends the
643                        // journey; the remaining neighbors are only a fallback
644                        // for that link failing.
645                        if deliver_direct && target == &recipient {
646                            debug!(
647                                message_id = %message.id,
648                                next_hop = %target,
649                                transport = ?transport,
650                                "Delivered to its recipient directly"
651                            );
652                            break;
653                        }
654                        debug!(
655                            message_id = %message.id,
656                            next_hop = %target,
657                            transport = ?transport,
658                            hop_count,
659                            remaining_ttl,
660                            "Forwarded frame to neighbor"
661                        );
662                    }
663                    Err(err) => {
664                        // A link that went away between selection and send.
665                        // The other targets still carry the frame.
666                        debug!(
667                            message_id = %message.id,
668                            next_hop = %target,
669                            error = %err,
670                            "Could not forward to neighbor"
671                        );
672                    }
673                }
674            }
675
676            // Nothing reached a neighbor. Hand it back instead of dropping it:
677            // its id is already recorded as handled here, so a drop would lose
678            // this copy *and* refuse both the copies arriving behind it and the
679            // sender's retransmissions, for the whole retention window.
680            //
681            // Why it got nowhere does not change that. The budget ran out (the
682            // frames released alongside this one spent the allowance between
683            // them), every link chosen for it went away between being picked
684            // and being written to, or there was no usable link to pick. In all
685            // three the frame has travelled nowhere and this queue is the only
686            // thing that still remembers it. It keeps its due time, so one that
687            // stays stuck is abandoned by the overdue cut-off rather than
688            // retried forever.
689            if delivered_to == 0 {
690                self.mesh_relay.requeue(relay);
691                continue;
692            }
693
694            self.mesh_relay.record_forwarded();
695            self.emit_event(Event::message_relayed(
696                message.id.as_str(),
697                message.sender.as_str().to_string(),
698                recipient,
699                hop_count,
700                remaining_ttl,
701            ));
702        }
703    }
704
705    /// [`Self::handle_incoming_file_chunk_via`] with no known arrival transport
706    /// (a deferred chunk falls back to the resend-driven ACK). Used only by
707    /// tests; production always routes through the `_via` form with the inbound
708    /// transport.
709    #[cfg(test)]
710    pub(super) fn handle_incoming_file_chunk(&mut self, message: &Message) -> ChunkOutcome {
711        self.handle_incoming_file_chunk_via(message, None)
712    }
713
714    /// Routes an inbound file-chunk message through the transfer manager,
715    /// recording `arrival_transport` on the pending entry if the chunk must be
716    /// deferred, so the drain can ACK it directly once the session confirms.
717    pub(super) fn handle_incoming_file_chunk_via(
718        &mut self,
719        message: &Message,
720        arrival_transport: Option<TransportType>,
721    ) -> ChunkOutcome {
722        let sender = message.sender.as_str().to_string();
723
724        // SEC-H1: encrypted chunks carry the chunk bytes, media metadata, and
725        // original content type inside the MLS ciphertext; legacy plaintext
726        // chunks carry them on the wire Message and are accepted only where
727        // policy allows. Rich extras (caption, reply, forward) only ever come
728        // from the sealed plaintext — never from the wire Message.
729        let (chunk, media_metadata, original_content_type, rich_extras) = if let Some(ref binary) =
730            message.binary_content
731        {
732            if is_media_envelope(binary) {
733                let encrypted = match decode_media_envelope(binary) {
734                    Ok(e) => e,
735                    // A frame whose magic byte still reads as a media envelope
736                    // but whose encoding does not decode — in-transit
737                    // corruption past the magic, or injected garbage. Under
738                    // crypto recovery this is the pre-decrypt sibling of a hard
739                    // decrypt failure: the frame as it stands is dead, but the
740                    // sender's resend carries a fresh encoding that would
741                    // decode, so withhold the ACK (`Deferred`) instead of
742                    // telling the sender "delivered" for a chunk we dropped.
743                    // Do NOT enqueue: an unparseable frame can never become
744                    // parseable, so a queued copy could never drain — the same
745                    // reasoning as the spent-generation arm below.
746                    Err(e) => {
747                        warn!(
748                            message_id = %message.id,
749                            error = %e,
750                            "Failed to decode encrypted media envelope"
751                        );
752                        if !self.config.encryption.crypto_recovery_enabled {
753                            return ChunkOutcome::Handled;
754                        }
755                        // Advisory, not terminal — the chunk was never ACKed, so
756                        // the transfer is *stalled* pending a resend rather than
757                        // failed. The terminal media signal stays
758                        // `FileReceiveFailed`.
759                        if let Ok(state) = lock_shared_state(&self.shared_state) {
760                            state.emit_event(Event::message_decryption_failed(
761                                message.id.clone(),
762                                sender.clone(),
763                                crate::events::DecryptionFailureCode::InvalidPayload,
764                                "encrypted media envelope failed to decode; its file transfer is stalled until the sender resends".to_string(),
765                            ));
766                        }
767                        return ChunkOutcome::Deferred;
768                    }
769                };
770                let plaintext =
771                    match self.decrypt_media_chunk(&sender, &encrypted, message, arrival_transport)
772                    {
773                        MediaChunkDecrypt::Plaintext(p) => p,
774                        // Not delivered and recoverable by a resend (queued for
775                        // delayed decryption, or an undecryptable frame the
776                        // sender can re-send): defer the ACK so the sender
777                        // retries and the resend re-enters processing.
778                        MediaChunkDecrypt::Deferred => return ChunkOutcome::Deferred,
779                        // Illegitimate frame (foreign session slot, or an MLS
780                        // credential naming a different sender): answer with
781                        // silence, exactly like the text `SecurityRejected`
782                        // path. ACKing here would tell an injector that the
783                        // target is online and processing their frames — the
784                        // one thing the text path withholds.
785                        MediaChunkDecrypt::SecurityRejected => return ChunkOutcome::Rejected,
786                        // Terminal drop (permanent refusal, empty plaintext, MLS
787                        // unavailable): ACK as before — the sender cannot
788                        // recover it by retrying.
789                        MediaChunkDecrypt::Dropped => return ChunkOutcome::Handled,
790                    };
791                let inner = match MediaChunkPlaintext::decode(&plaintext) {
792                    Ok(i) => i,
793                    Err(e) => {
794                        warn!(
795                            message_id = %message.id,
796                            error = %e,
797                            "Failed to parse decrypted media chunk plaintext, dropping"
798                        );
799                        return ChunkOutcome::Handled;
800                    }
801                };
802                let chunk = match FileChunk::from_bytes(&inner.chunk_bytes) {
803                    Ok(c) => c,
804                    Err(e) => {
805                        warn!(
806                            message_id = %message.id,
807                            error = %e,
808                            "Failed to deserialize decrypted file chunk, dropping"
809                        );
810                        return ChunkOutcome::Handled;
811                    }
812                };
813                (
814                    chunk,
815                    inner.media_metadata,
816                    inner.original_content_type,
817                    inner.rich_extras,
818                )
819            } else {
820                if !self.accept_plaintext_content(&sender) {
821                    warn!(
822                        message_id = %message.id,
823                        sender = %sender,
824                        "Rejecting unencrypted media chunk (encryption policy)"
825                    );
826                    self.warn_plaintext_receive_rejected(
827                        &sender,
828                        "Inbound plaintext media chunk rejected by encryption policy",
829                    );
830                    return ChunkOutcome::Rejected;
831                }
832                let chunk = match FileChunk::from_bytes(binary) {
833                    Ok(c) => c,
834                    Err(e) => {
835                        warn!(
836                            message_id = %message.id,
837                            error = %e,
838                            "Failed to deserialize binary file chunk, dropping"
839                        );
840                        return ChunkOutcome::Handled;
841                    }
842                };
843                let (meta, oct) = Self::wire_media_metadata(message);
844                (chunk, meta, oct, None)
845            }
846        } else {
847            if !self.accept_plaintext_content(&sender) {
848                warn!(
849                    message_id = %message.id,
850                    sender = %sender,
851                    "Rejecting unencrypted media chunk (encryption policy)"
852                );
853                self.warn_plaintext_receive_rejected(
854                    &sender,
855                    "Inbound plaintext media chunk rejected by encryption policy",
856                );
857                return ChunkOutcome::Rejected;
858            }
859            let chunk = match FileChunk::from_json(&message.content) {
860                Ok(c) => c,
861                Err(e) => {
862                    warn!(
863                        message_id = %message.id,
864                        error = %e,
865                        "Failed to deserialize file chunk, dropping"
866                    );
867                    return ChunkOutcome::Handled;
868                }
869            };
870            let (meta, oct) = Self::wire_media_metadata(message);
871            (chunk, meta, oct, None)
872        };
873
874        let file_id = chunk.file_id.clone();
875        let file_name = chunk.file_name.clone();
876        let file_size = chunk.file_size;
877        let is_first_chunk = chunk.chunk_index == 0;
878
879        // Metadata is recorded only for chunks the manager accepts — a
880        // rejected chunk must leave no state behind (SEC-H2).
881        match self.file_transfer_manager.process_chunk(&sender, chunk) {
882            Ok(progress) => {
883                if is_first_chunk {
884                    self.pending_media_metadata.insert(
885                        file_id.clone(),
886                        PendingMediaMetadataEntry {
887                            content_type: original_content_type.unwrap_or(ContentType::File),
888                            media_metadata,
889                            last_updated_at: Instant::now(),
890                            sender: sender.clone(),
891                            rich_extras,
892                            timestamp_ms: message.timestamp.as_millis(),
893                        },
894                    );
895                } else if let Some(entry) = self.pending_media_metadata.get_mut(&file_id) {
896                    entry.last_updated_at = Instant::now();
897                }
898                if let Ok(state) = lock_shared_state(&self.shared_state) {
899                    state.emit_event(Event::file_progress(
900                        file_id.clone(),
901                        progress.chunks_completed,
902                        progress.total_chunks,
903                    ));
904                }
905            }
906            Err(rejection) if rejection.is_resource_exhaustion() => {
907                // A well-formed transfer was dropped by a receiver-side
908                // resource limit. The chunk was already ACKed and will not
909                // be retransmitted, so the transfer is unrecoverable —
910                // surface that to the application instead of going silent.
911                self.pending_media_metadata.remove(&file_id);
912                if let Ok(state) = lock_shared_state(&self.shared_state) {
913                    state.emit_event(Event::file_receive_failed(
914                        file_id,
915                        file_name,
916                        sender,
917                        rejection.as_str().to_string(),
918                    ));
919                }
920                return ChunkOutcome::Handled;
921            }
922            // Malformed or mismatched chunks are logged by the manager; the
923            // assembly they targeted (if any) stays intact. Chunks of an
924            // already-failed transfer land here too (`previously_failed`) —
925            // its FileReceiveFailed event already fired exactly once.
926            Err(_) => return ChunkOutcome::Handled,
927        }
928
929        if self.file_transfer_manager.is_complete(&file_id) {
930            let Some(file_data) = self.file_transfer_manager.finalize_file(&file_id) else {
931                warn!(
932                    file_id = %file_id,
933                    "File transfer marked complete but reassembly or integrity checks failed"
934                );
935                self.pending_media_metadata.remove(&file_id);
936                if let Ok(state) = lock_shared_state(&self.shared_state) {
937                    state.emit_event(Event::file_receive_failed(
938                        file_id,
939                        file_name,
940                        sender,
941                        "integrity_check_failed".to_string(),
942                    ));
943                }
944                return ChunkOutcome::Handled;
945            };
946            let metadata_entry = self.pending_media_metadata.remove(&file_id);
947            let (content_type, media_metadata, rich_extras, timestamp_ms) = metadata_entry
948                .map(|entry| {
949                    (
950                        entry.content_type,
951                        entry.media_metadata,
952                        entry.rich_extras,
953                        Some(entry.timestamp_ms),
954                    )
955                })
956                .unwrap_or((ContentType::File, None, None, None));
957            let rich_extras = rich_extras.unwrap_or_default();
958
959            if let Ok(state) = lock_shared_state(&self.shared_state) {
960                state.emit_event(Event::file_received(
961                    file_id,
962                    file_name,
963                    file_size,
964                    sender,
965                    content_type,
966                    media_metadata,
967                    file_data,
968                    timestamp_ms,
969                    rich_extras.caption,
970                    rich_extras.reply_to_msg,
971                    rich_extras.reply_context.as_ref(),
972                    rich_extras.forward_info.as_ref(),
973                ));
974            }
975        }
976
977        ChunkOutcome::Handled
978    }
979
980    /// Extracts the chunk-0 metadata a legacy (unencrypted) chunk carries on
981    /// the wire `Message`.
982    fn wire_media_metadata(message: &Message) -> (Option<MediaMetadata>, Option<ContentType>) {
983        use crate::constants::ORIGINAL_CONTENT_TYPE_KEY;
984        let original_ct = message
985            .metadata
986            .get(ORIGINAL_CONTENT_TYPE_KEY)
987            .map(|s| ContentType::parse(s));
988        (message.media_metadata.clone(), original_ct)
989    }
990
991    /// Policy gate for inbound plaintext content — text messages and legacy
992    /// (unencrypted) media chunks alike: rejected when this node requires
993    /// encryption, and rejected once the sender is known to run MLS — an
994    /// encryption-capable peer sending plaintext is a downgrade/forgery attempt
995    /// (plaintext carries no sender authentication, so anyone could inject it
996    /// under a contact's name).
997    ///
998    /// # Capability, not confirmation
999    ///
1000    /// This asks whether the peer *can* encrypt, not whether a session is
1001    /// confirmed. The distinction is the whole gate: a sender only ever emits
1002    /// plaintext when its own `should_auto_encrypt()` is false, which precludes
1003    /// it having established a session with us — while the session is merely
1004    /// pending it *queues* (`prepare_outbound_content`) rather than downgrading.
1005    /// So no honest peer sends cleartext while we know it speaks MLS, and
1006    /// asking only about *confirmed* sessions left a hole: an attacker with
1007    /// app-container write access deletes the `session_states` record, restore
1008    /// re-bootstraps it as `Pending` (which it must), the peer silently drops
1009    /// out of `confirmed_sessions`, and this gate re-opens. See
1010    /// [`OfflineProtocol::encryption_capable_peers`] for why that signal now
1011    /// comes from the credential store instead.
1012    ///
1013    /// The capability check is also *cheaper* than the confirmation one it
1014    /// precedes: it is an in-memory set lookup, where `is_session_confirmed`
1015    /// falls through to a protocol-state read plus a sealed-record open for any
1016    /// sender not already in `confirmed_sessions` — a path driven by the
1017    /// attacker-controlled `message.sender`. Ordering the cheap check first
1018    /// makes a flood of forged sender ids cost less than it used to.
1019    ///
1020    /// The group path has gated on existence rather than confirmation since it
1021    /// was written (`has_mls_group_state`); this brings the 1:1 path into line.
1022    fn accept_plaintext_content(&mut self, sender: &str) -> bool {
1023        if self.config.encryption.require_encryption {
1024            return false;
1025        }
1026        // `should_auto_encrypt()` guards both checks: a node with encryption
1027        // disabled or MLS uninitialized has no standing to call anything a
1028        // downgrade, and removing this guard would reject legitimate plaintext
1029        // on every plaintext-only deployment.
1030        if !self.should_auto_encrypt() {
1031            return true;
1032        }
1033        if self.is_encryption_capable(sender) {
1034            return false;
1035        }
1036        // Fail closed: if the confirmation lookup errors (storage failure),
1037        // treat the session as confirmed and reject — accepting plaintext on
1038        // error would let a storage fault disable the downgrade gate.
1039        if self.is_session_confirmed(sender).unwrap_or(true) {
1040            return false;
1041        }
1042        true
1043    }
1044
1045    /// Decrypts an encrypted media chunk envelope. Four failure dispositions,
1046    /// mirroring the text path in [`Self::handle_encrypted_message`]:
1047    ///
1048    /// - **Session not ready**: the whole message is queued for delayed
1049    ///   decryption and [`MediaChunkDecrypt::Deferred`] is returned.
1050    /// - **Recoverable** (epoch desync, or a crypto/transport failure while
1051    ///   `crypto_recovery_enabled`): [`MediaChunkDecrypt::Deferred`] *without*
1052    ///   queueing — the ciphertext is dead either way, so recovery is the
1053    ///   sender's resend, driven by the withheld ACK.
1054    /// - **Security rejection** (the envelope names another pair's session
1055    ///   slot, or the MLS credential authenticates a different sender than the
1056    ///   wire envelope claims): [`MediaChunkDecrypt::SecurityRejected`], which
1057    ///   the caller answers with silence. Deliberately *not* gated on
1058    ///   `crypto_recovery_enabled` — this is about what the receiver reveals,
1059    ///   not about recovery, and the text path's equivalent is unconditional.
1060    /// - **Terminal** (a permanent refusal, an empty plaintext, or any crypto
1061    ///   failure with recovery switched off): decryption telemetry plus
1062    ///   [`MediaChunkDecrypt::Dropped`], which the caller still ACKs.
1063    ///
1064    /// On `Deferred` or `SecurityRejected` the caller must skip the ACK and
1065    /// unmark the id — for the former so the sender keeps retrying, for the
1066    /// latter so a replay re-enters this gate rather than hitting the duplicate
1067    /// re-ACK path. A successful decrypt doubles as a session confirmation
1068    /// signal, exactly like text decrypts.
1069    fn decrypt_media_chunk(
1070        &mut self,
1071        sender: &str,
1072        encrypted: &EncryptedMessage,
1073        message: &Message,
1074        arrival_transport: Option<TransportType>,
1075    ) -> MediaChunkDecrypt {
1076        let group_id = encrypted.group_id.as_str().to_string();
1077
1078        // Media envelopes are only ever produced for the sender's 1:1 session,
1079        // whose MLS group id is deterministic. Enforce that binding before
1080        // decrypting: MLS authenticates group membership, not the wire sender
1081        // claim, so without this check any peer holding a valid session with
1082        // us could deliver its own ciphertext under an arbitrary
1083        // `message.sender` and have the file attributed to that identity.
1084        let Ok(expected_group) = GroupId::for_session(&self.local_id, sender) else {
1085            warn!(
1086                sender = %sender,
1087                "Cannot derive 1:1 session id for media chunk sender, dropping"
1088            );
1089            return MediaChunkDecrypt::Dropped;
1090        };
1091        if encrypted.group_id != expected_group {
1092            error!(
1093                sender = %sender,
1094                group_id = %group_id,
1095                expected = %expected_group,
1096                "SECURITY: encrypted media chunk MLS group does not match the claimed sender's session, rejecting"
1097            );
1098            self.emit_security_warning(
1099                sender,
1100                SecurityWarningCode::MediaSenderGroupMismatch,
1101                "Encrypted media chunk MLS group does not match the claimed sender",
1102            );
1103            // Not ACKed: the text path answers the identical condition
1104            // (`MlsError::SessionIdentityMismatch` →
1105            // `InternalMessageResult::SecurityRejected`) with silence, and an
1106            // ACK here would leak exactly what that silence protects — an
1107            // injector who gets an ACK for a media chunk but nothing for the
1108            // same text frame learns the target is live either way.
1109            return MediaChunkDecrypt::SecurityRejected;
1110        }
1111
1112        let Some(mls) = self.mls_manager.clone() else {
1113            warn!(
1114                sender = %sender,
1115                "Encrypted media chunk received but MLS is not initialized, dropping"
1116            );
1117            self.emit_mls_decryption_failed(
1118                sender,
1119                Some(&group_id),
1120                DecryptionFailureKind::NotInitialized,
1121                MlsOperationContext::Receive,
1122            );
1123            return MediaChunkDecrypt::Dropped;
1124        };
1125
1126        let decrypt_result = {
1127            let manager = match mls.read() {
1128                Ok(m) => m,
1129                Err(_) => {
1130                    error!("MLS lock poisoned while decrypting media chunk");
1131                    return MediaChunkDecrypt::Dropped;
1132                }
1133            };
1134            manager.decrypt(encrypted, sender)
1135        };
1136
1137        match decrypt_result {
1138            Ok(Some(plaintext)) => {
1139                // A group-aware decrypt is the same confirmation signal the
1140                // text path uses; skip when already cache-confirmed to avoid
1141                // per-chunk storage I/O.
1142                if !self.confirmed_sessions.contains(sender) {
1143                    self.confirm_session_from_successful_decrypt(sender, &group_id);
1144                }
1145                MediaChunkDecrypt::Plaintext(plaintext)
1146            }
1147            Ok(None) => {
1148                warn!(sender = %sender, "Media chunk decryption returned empty, dropping");
1149                MediaChunkDecrypt::Dropped
1150            }
1151            // Intercepted BEFORE classification, mirroring the text path in
1152            // `handle_encrypted_message`. Both variants classify as
1153            // `SessionStateError::Unknown`, whose disposition must stay
1154            // terminal-drop-and-ACK for the refusals that genuinely belong
1155            // there (`CommitNotAuthorized`), so the split has to happen here
1156            // rather than in the classifier.
1157            Err(ref e) if is_media_security_rejection(e) => {
1158                error!(
1159                    sender = %sender,
1160                    error = %e,
1161                    "SECURITY: encrypted media chunk failed its sender/session identity check, rejecting"
1162                );
1163                MediaChunkDecrypt::SecurityRejected
1164            }
1165            Err(e) => {
1166                let classification = SessionStateError::from(&e);
1167                match classification {
1168                    SessionStateError::SessionNotReady | SessionStateError::GroupNotFound => {
1169                        info!(
1170                            sender = %sender,
1171                            error_code = classification.code(),
1172                            "Encrypted media chunk received before session ready, queuing"
1173                        );
1174                        self.emit_mls_session_missing(
1175                            Some(sender),
1176                            Some(&group_id),
1177                            MlsOperationContext::SessionLookup,
1178                            MlsErrorCategory::SessionStateMissing,
1179                        );
1180                        self.enqueue_pending_decryption_via(sender, message, arrival_transport);
1181                        MediaChunkDecrypt::Deferred
1182                    }
1183                    SessionStateError::SessionDesync
1184                        if self.config.encryption.crypto_recovery_enabled =>
1185                    {
1186                        // Epoch desync (see the text path in `handle_encrypted_message`):
1187                        // heal the channel with a rate-limited re-key and return
1188                        // Deferred so the chunk is not ACKed and its id is
1189                        // unmarked. We do NOT enqueue — the chunk is sealed to the
1190                        // dead epoch and can never decrypt after the re-key.
1191                        //
1192                        // Unlike a DM, an in-flight media chunk cannot be
1193                        // re-sealed for recovery: Tier 2 reseal is a deliberate
1194                        // no-op for media (the media outbox is not persisted and
1195                        // chunks are re-encoded, not replayed). Withholding the
1196                        // ACK here is what drives recovery: the sender's media
1197                        // outbox keeps retrying and, once its retry/ACK-timeout
1198                        // budget lapses, surfaces `MediaResendRequired`, and the
1199                        // app re-supplies the bytes via `send_media_with` — which
1200                        // re-encodes fresh chunks against the now-healed session.
1201                        // So media recovers via descriptor-based resend, not reseal.
1202                        info!(
1203                            sender = %sender,
1204                            error_code = classification.code(),
1205                            "Encrypted media chunk failed to decrypt due to epoch desync, re-keying"
1206                        );
1207                        self.schedule_session_rekey(sender);
1208                        MediaChunkDecrypt::Deferred
1209                    }
1210                    SessionStateError::TransportFailure | SessionStateError::CryptoFailure
1211                        if self.config.encryption.crypto_recovery_enabled =>
1212                    {
1213                        // A genuine crypto/transport failure on a chunk (AEAD
1214                        // failure, spent ratchet generation, malformed frame).
1215                        // Mirrors the text path in `handle_encrypted_message`:
1216                        // withhold the ACK and unmark the id rather than lying
1217                        // "delivered" for a chunk we dropped. Do NOT enqueue —
1218                        // the generation is spent, so a queued copy could never
1219                        // drain — and do NOT re-key, which stays desync-only.
1220                        //
1221                        // Recovery differs from a DM: chunks have no Tier 2
1222                        // re-seal (they are re-encoded, not replayed), so the
1223                        // un-ACKed chunk drives the media outbox's retry/ACK
1224                        // budget to lapse and surface `MediaResendRequired`,
1225                        // and the app re-supplies the bytes via `send_media_with`
1226                        // — re-encoded against a live generation. Same
1227                        // descriptor-based recovery as the media desync arm.
1228                        warn!(
1229                            sender = %sender,
1230                            error = %e,
1231                            error_code = classification.code(),
1232                            "Failed to decrypt media chunk; withholding ACK so the transfer can be resent"
1233                        );
1234                        let kind = DecryptionFailureKind::from_mls_error(&e);
1235                        self.emit_mls_decryption_failed(
1236                            sender,
1237                            Some(&group_id),
1238                            kind,
1239                            MlsOperationContext::Receive,
1240                        );
1241                        // Advisory, not terminal — matching a pending-queue
1242                        // eviction: the chunk was never ACKed, so the transfer
1243                        // is *stalled* pending a resend, not failed. The
1244                        // terminal media signal is still `FileReceiveFailed`.
1245                        if let Ok(state) = lock_shared_state(&self.shared_state) {
1246                            state.emit_event(Event::message_decryption_failed(
1247                                message.id.clone(),
1248                                sender.to_string(),
1249                                Self::decryption_failure_code_from_kind(kind),
1250                                format!(
1251                                    "encrypted media chunk failed to decrypt ({}); its file transfer is stalled until the sender resends",
1252                                    classification.code()
1253                                ),
1254                            ));
1255                        }
1256                        MediaChunkDecrypt::Deferred
1257                    }
1258                    _ => {
1259                        warn!(
1260                            sender = %sender,
1261                            error = %e,
1262                            error_code = classification.code(),
1263                            "Failed to decrypt media chunk, dropping"
1264                        );
1265                        let kind = DecryptionFailureKind::from_mls_error(&e);
1266                        self.emit_mls_decryption_failed(
1267                            sender,
1268                            Some(&group_id),
1269                            kind,
1270                            MlsOperationContext::Receive,
1271                        );
1272                        // Permanently undecryptable (or crypto recovery is
1273                        // switched off): the chunk is ACKed and stays
1274                        // dedup-marked, so this loss is terminal for the
1275                        // transfer — surface it to the app, not just as MLS
1276                        // telemetry.
1277                        if let Ok(state) = lock_shared_state(&self.shared_state) {
1278                            state.emit_event(Event::message_decryption_failed(
1279                                message.id.clone(),
1280                                sender.to_string(),
1281                                Self::decryption_failure_code_from_kind(kind),
1282                                format!(
1283                                    "encrypted media chunk failed to decrypt ({}); its file transfer cannot complete",
1284                                    classification.code()
1285                                ),
1286                            ));
1287                        }
1288                        MediaChunkDecrypt::Dropped
1289                    }
1290                }
1291            }
1292        }
1293    }
1294}
1295
1296/// Four-way outcome of [`OfflineProtocol::decrypt_media_chunk`]: the plaintext,
1297/// a deferral (not delivered, but recoverable by the sender's resend — either
1298/// queued because the session is not ready, or dropped as undecryptable with
1299/// the ACK withheld), a security rejection (an illegitimate frame the caller
1300/// must answer with silence), or a terminal drop the caller ACKs.
1301enum MediaChunkDecrypt {
1302    Plaintext(Vec<u8>),
1303    Deferred,
1304    /// The frame is not a legitimate message from the claimed sender: its
1305    /// envelope named another pair's session slot, or the MLS credential
1306    /// authenticated a different member than the wire envelope claims. Mirrors
1307    /// the text path's [`InternalMessageResult::SecurityRejected`] — the caller
1308    /// must NOT ACK (an ACK confirms to an injector that the target is online
1309    /// and processing their frames, which is exactly what the text path refuses
1310    /// to reveal) and must unmark the id.
1311    ///
1312    /// [`InternalMessageResult::SecurityRejected`]: super::InternalMessageResult::SecurityRejected
1313    SecurityRejected,
1314    Dropped,
1315}
1316
1317/// Whether an MLS decrypt error is a *security* rejection rather than a session
1318/// state problem — the two classes for which the receiver must stay silent
1319/// instead of ACKing.
1320///
1321/// Used as the match guard that intercepts these variants *before*
1322/// [`SessionStateError`] classification, exactly as the text path does inline in
1323/// `handle_encrypted_message`. The intercept cannot be replaced by a
1324/// classification arm: both variants map to [`SessionStateError::Unknown`], and
1325/// `Unknown` must keep its terminal drop-and-ACK disposition for the classes
1326/// that genuinely belong there (notably `CommitNotAuthorized`, a permanent
1327/// refusal whose sender gains nothing from retrying).
1328///
1329/// Kept as a named predicate rather than an inline pattern so the classification
1330/// itself is testable: `SenderIdentityMismatch` is not reachable through any
1331/// SDK-built ciphertext (see
1332/// `test_media_security_rejection_classifies_both_identity_mismatches`).
1333pub(super) fn is_media_security_rejection(error: &offline_protocol_mls::MlsError) -> bool {
1334    matches!(
1335        error,
1336        offline_protocol_mls::MlsError::SenderIdentityMismatch { .. }
1337            | offline_protocol_mls::MlsError::SessionIdentityMismatch { .. }
1338            // The leaf-binding refusals join them for the same reason: an
1339            // `__MLS_ENC__` envelope naming a `group:` id reaches group decrypt
1340            // through this path, both classify as `Unknown`, and `Unknown` must
1341            // keep drop-and-ACK for `CommitNotAuthorized`.
1342            | offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1343            | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1344    )
1345}