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    /// 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}