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;
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 and no event, so the block
196                    // reveals nothing to the sender while it lasts.
197                    //
198                    // The id IS remembered. A message sent during a block is
199                    // never delivered, whenever the block ends: that is what a
200                    // user who blocked someone expects, and it is the inbound
201                    // half of the post-unblock reset, which already drops our
202                    // own queued messages to that peer. Without this, a sender
203                    // whose retry ladder was still running when the block was
204                    // lifted delivered everything it had sent meanwhile. Now
205                    // such a resend meets the duplicate path above, which
206                    // answers it once the block is gone, so the sender stops
207                    // retrying.
208                    if sender_blocked {
209                        debug!(
210                            sender = %message.sender,
211                            message_id = %message.id,
212                            "Dropping message from blocked user"
213                        );
214                        self.mark_seen_persisted(message.id.clone());
215                        continue;
216                    }
217
218                    self.mark_seen_persisted(message.id.clone());
219
220                    // Handle internal MLS messages
221                    let mut was_decrypted = false;
222                    if let Some(result) =
223                        self.process_internal_message_via(&message, Some(transport_used))
224                    {
225                        match result {
226                            InternalMessageResult::Consumed => {
227                                // Internal control messages are still delivery-sensitive for
228                                // the sender (invites/accept/welcome). ACK before consume.
229                                if message.requires_ack {
230                                    if let Err(err) =
231                                        self.send_delivery_ack(&message, transport_used)
232                                    {
233                                        error!(
234                                            message_id = %message.id,
235                                            error = %err,
236                                            "Failed to send delivery ACK for internal message"
237                                        );
238                                    }
239                                }
240                                // Internal message handled, don't surface to app
241                                continue;
242                            }
243                            InternalMessageResult::SecurityRejected => {
244                                // Security gate rejected this message (spoofed sender,
245                                // bad signature, unsigned control traffic, or a signing
246                                // key that does not derive to the claimed sender
247                                // address). Do NOT send a delivery
248                                // ACK — acknowledging would confirm to the attacker that
249                                // the target peer is online and processing messages.
250                                // Forget the id too, or an exact replay would hit the
251                                // duplicate re-ACK path above and leak that presence
252                                // anyway; reprocessing a replay costs no more than a
253                                // fresh forged message.
254                                self.unmark_seen_persisted(&message.id);
255                                continue;
256                            }
257                            InternalMessageResult::Deferred => {
258                                // The message was not delivered, but the sender
259                                // can still recover it by resending: it could not
260                                // be decrypted *yet* (session not ready, so queued
261                                // for delayed decryption), or its envelope did not
262                                // parse. See the conditions on
263                                // `InternalMessageResult::Deferred`.
264                                //
265                                // Do NOT send a delivery ACK and do NOT keep the id
266                                // dedup-marked: the message is not delivered, so the
267                                // sender must be free to retry and that retry must
268                                // re-enter processing rather than hit the duplicate
269                                // re-ACK path above. For a queued copy, the session
270                                // confirming drains it: the message is surfaced and
271                                // the id re-marked (see `process_pending_decryption`),
272                                // so the sender's next resend is then deduped +
273                                // re-ACKed. For an unparseable envelope, recovery
274                                // is that resend itself.
275                                self.unmark_seen_persisted(&message.id);
276                                continue;
277                            }
278                            InternalMessageResult::Undecryptable => {
279                                // As `Deferred`, but answered: the sender's
280                                // re-sealed resend must still re-enter
281                                // processing, and whoever holds this copy
282                                // must stop redelivering it.
283                                self.unmark_seen_persisted(&message.id);
284                                self.send_undecryptable_ack(&message, transport_used);
285                                continue;
286                            }
287                            InternalMessageResult::Decrypted(plaintext) => {
288                                was_decrypted = true;
289                                Self::apply_decrypted_content(&mut message, plaintext);
290                            }
291                        }
292                    }
293
294                    // Policy gate for inbound plaintext (SEC: inbound text must
295                    // honor require_encryption exactly like legacy media does).
296                    // A message that reaches this point without decryption is
297                    // unauthenticated cleartext with an attacker-controllable
298                    // sender. FileChunk messages are exempt: encrypted media
299                    // envelopes carry no content prefix (the ciphertext rides
300                    // in binary_content), so they are indistinguishable from
301                    // plaintext here — handle_incoming_file_chunk applies this
302                    // same gate after telling the two apart. Rejection sends
303                    // no delivery ACK, mirroring SecurityRejected: don't
304                    // confirm to an injector that the target processes their
305                    // messages. The id is forgotten so a replay re-enters
306                    // this gate instead of the duplicate re-ACK path.
307                    if !was_decrypted
308                        && message.content_type != ContentType::FileChunk
309                        && !self.accept_plaintext_content(message.sender.as_str())
310                    {
311                        warn!(
312                            message_id = %message.id,
313                            sender = %message.sender,
314                            "Rejecting unencrypted inbound message (encryption policy)"
315                        );
316                        self.warn_plaintext_receive_rejected(
317                            message.sender.as_str(),
318                            "Inbound plaintext message rejected by encryption policy",
319                        );
320                        self.unmark_seen_persisted(&message.id);
321                        continue;
322                    }
323
324                    // Route file-chunk messages to the transfer manager BEFORE
325                    // the delivery ACK, and never surface them to the app as
326                    // regular messages. An encrypted chunk that was not
327                    // delivered but that the sender can still recover by
328                    // resending — not decryptable yet (session not ready, so
329                    // queued for delayed decryption), or undecryptable as it
330                    // stands (epoch desync, crypto failure) — returns
331                    // `Deferred`: it must NOT be ACKed and its id is unmarked,
332                    // so the sender keeps retrying and the resend re-enters
333                    // processing, matching the text `Deferred` path. Every other
334                    // outcome (`Handled`: decrypted/assembled, or a terminal
335                    // drop) is ACKed as before, since the sender cannot recover
336                    // it by retrying.
337                    if message.content_type == ContentType::FileChunk {
338                        match self.handle_incoming_file_chunk_via(&message, Some(transport_used)) {
339                            ChunkOutcome::Deferred => {
340                                self.unmark_seen_persisted(&message.id);
341                            }
342                            // Plaintext chunk rejected by encryption policy:
343                            // withhold the ACK and unmark the id, exactly like
344                            // the plaintext-text rejection above and the text
345                            // `SecurityRejected` path — don't confirm to an
346                            // injector that we process their messages, and let a
347                            // replay re-enter the gate rather than hit the
348                            // duplicate re-ACK path.
349                            ChunkOutcome::Rejected => {
350                                self.unmark_seen_persisted(&message.id);
351                            }
352                            ChunkOutcome::Handled => {
353                                if message.requires_ack {
354                                    if let Err(err) =
355                                        self.send_delivery_ack(&message, transport_used)
356                                    {
357                                        error!(
358                                            message_id = %message.id,
359                                            error = %err,
360                                            "Failed to send delivery ACK for media chunk"
361                                        );
362                                    }
363                                }
364                            }
365                        }
366                        continue;
367                    }
368
369                    if message.requires_ack {
370                        if let Err(err) = self.send_delivery_ack(&message, transport_used) {
371                            error!(
372                                message_id = %message.id,
373                                error = %err,
374                                "Failed to send delivery ACK"
375                            );
376                        }
377                    }
378
379                    let forward_info = message
380                        .forwarded_from
381                        .as_ref()
382                        .map(crate::events::ForwardInfoEvent::from);
383
384                    let event = Event::MessageReceived {
385                        message_id: message.id.as_str(),
386                        sender: message.sender.as_str().to_string(),
387                        recipient: message.recipient.as_str().to_string(),
388                        content: message.content.clone(),
389                        hop_count: message.hop_count.value(),
390                        transport: transport_used.to_string(),
391                        timestamp: message.timestamp.as_millis(),
392                        lamport_clock: message.lamport_clock.value(),
393                        reply_to_msg: message
394                            .reply_to_msg
395                            .as_ref()
396                            .map(|id| id.as_str().to_string()),
397                        reply_context: message
398                            .reply_context
399                            .as_ref()
400                            .map(|rc| Box::new(crate::events::ReplyContextEvent::from(rc))),
401                        content_type: message.content_type.to_string(),
402                        media_metadata: message.media_metadata.clone(),
403                        forward_info,
404                        encrypted: was_decrypted,
405                        app_id: message.app_id.as_str().to_string(),
406                    };
407
408                    let Ok(state) = lock_shared_state(&self.shared_state) else {
409                        error!("Failed to lock shared state for message received event");
410                        return None;
411                    };
412                    state.emit_event(event);
413                    drop(state);
414
415                    return Some(message);
416                }
417                Ok(None) => return None,
418                Err(err) => {
419                    error!(error = %err, "Transport receive error");
420                    return None;
421                }
422            }
423        }
424    }
425
426    /// Considers an inbound frame addressed to a third party for forwarding.
427    ///
428    /// Learns the route back toward the sender, then offers the frame to the
429    /// governor, which decides whether it travels any further. Nothing is
430    /// transmitted here: an accepted frame is queued and goes out from
431    /// [`Self::flush_mesh_relays`] once its delay elapses, which is what gives
432    /// a neighbor the chance to cover it first.
433    ///
434    /// `arrival_peer` is the neighbor that handed us this frame, when the
435    /// carrier identified the link.
436    fn try_relay_message(&mut self, message: &Message, arrival_peer: Option<&str>) {
437        // Cheapest answer first. This device sees the whole neighborhood's
438        // traffic, so in a crowded room most third-party arrivals are copies of
439        // a frame it has already dealt with — and everything below would be
440        // thrown away by the suppression check inside `admit`: a route-table
441        // write, a battery snapshot that locks and allocates across every
442        // transport, and an enumeration of every link. Answering a duplicate
443        // costs a hash lookup instead.
444        //
445        // Route learning is skipped along with the rest, which is deliberate:
446        // the copy teaches nothing the first one did not, and the table is not
447        // what forwarding decisions are made from. A duplicate we still hold a
448        // pending copy of is *not* absorbed here: it narrows that forward's
449        // fan-out, which `admit` records, so that case still takes the full
450        // path.
451        if self
452            .mesh_relay
453            .absorb_settled_duplicate(message, arrival_peer)
454        {
455            return;
456        }
457
458        // Carrying traffic for others costs this device's radio and battery,
459        // so it stays a local decision.
460        let relay_allowed = self.config.relay.allow_relay
461            && self.config.relay.relay_priority != RelayPriority::Never;
462
463        if !relay_allowed {
464            debug!(
465                message_id = %message.id,
466                "Not forwarding: relaying is disabled on this device"
467            );
468            return;
469        }
470
471        // Carrying other people's messages is the first thing to give up when
472        // the battery is going: a device that spends its last few percent
473        // relaying cannot send its own message when its owner needs to. The
474        // device keeps sending and receiving its own traffic either way.
475        //
476        // An unknown battery level is treated as willing — most platforms
477        // report one, and refusing to carry anything on a device that simply
478        // does not publish a level would quietly remove it from the network.
479        if !self.battery_allows_relaying() {
480            debug!(
481                message_id = %message.id,
482                "Not forwarding: battery is below the level for carrying traffic"
483            );
484            return;
485        }
486
487        let degree = self.transport_manager.mesh_neighbors().len();
488        match self.mesh_relay.admit(message, arrival_peer, degree) {
489            MeshRelayAdmission::Queued => {
490                debug!(
491                    message_id = %message.id,
492                    recipient = %message.recipient,
493                    degree,
494                    "Queued frame for forwarding"
495                );
496            }
497            MeshRelayAdmission::Rejected(reason) => {
498                debug!(
499                    message_id = %message.id,
500                    ?reason,
501                    "Not forwarding"
502                );
503            }
504        }
505    }
506
507    /// Whether the battery is healthy enough to carry other people's traffic.
508    ///
509    /// Takes its own battery reading; the per-tick caller already holds one and
510    /// uses [`Self::battery_allows_relaying_with`] instead.
511    pub(super) fn battery_allows_relaying(&self) -> bool {
512        let (_statuses, available) = self.transport_manager.snapshot_status_and_available();
513        let (battery_level, is_charging) =
514            crate::telemetry::aggregator::device_battery_from_available(
515                self.transport_manager.current_transport(),
516                &available,
517            );
518        self.battery_allows_relaying_with(battery_level, is_charging)
519    }
520
521    /// [`Self::battery_allows_relaying`] against a reading the caller already
522    /// has.
523    ///
524    /// An unknown level means yes — see [`Self::try_relay_message`].
525    pub(super) fn battery_allows_relaying_with(
526        &self,
527        battery_level: Option<u8>,
528        is_charging: bool,
529    ) -> bool {
530        let Some(level) = battery_level else {
531            return true;
532        };
533        level >= self.relay_battery_floor(is_charging)
534    }
535
536    /// The battery level below which this device stops carrying other people's
537    /// traffic.
538    ///
539    /// [`RelayConfig::min_battery_for_relay`] is the floor in the ordinary
540    /// case, relaxed to the hard [`CRITICAL_RELAY_BATTERY_LEVEL`] for a device
541    /// that is either charging or configured [`RelayPriority::Always`] — the
542    /// two ways of saying "keep relaying anyway". The hard floor is never
543    /// crossed: it applies even to a configured minimum set below it, because
544    /// spending the last few percent on strangers' traffic leaves a device
545    /// unable to send its own.
546    pub(super) fn relay_battery_floor(&self, is_charging: bool) -> u8 {
547        let eager = matches!(self.config.relay.relay_priority, RelayPriority::Always);
548        if is_charging || eager {
549            CRITICAL_RELAY_BATTERY_LEVEL
550        } else {
551            self.config
552                .relay
553                .min_battery_for_relay
554                .max(CRITICAL_RELAY_BATTERY_LEVEL)
555        }
556    }
557
558    /// Transmits the forwards whose delay has elapsed.
559    ///
560    /// Called from the process tick. Each frame goes to a bounded set of
561    /// neighbors chosen by the governor, never back to the peer it came from or
562    /// to the peer that wrote it. A frame whose recipient is a neighbor of ours
563    /// is handed straight to them instead — the shortest path we can see.
564    pub(super) fn flush_mesh_relays(&mut self) {
565        self.flush_mesh_relays_at(Instant::now());
566    }
567
568    /// [`Self::flush_mesh_relays`] at a given instant, so a test can walk a
569    /// forward past the overdue cut-off without waiting it out.
570    pub(super) fn flush_mesh_relays_at(&mut self, now: Instant) {
571        let (due, abandoned) = self.mesh_relay.release_due(now);
572
573        // The drop point (`docs/spec/custody.md`): an ordinary forward here
574        // waited past the overdue cut-off without reaching a link, and its
575        // id is already released from the suppression cache; custody may
576        // take it on. A marked held forward goes back to the store, unjudged
577        // and uncounted.
578        for relay in abandoned {
579            self.judge_at_drop_point(relay);
580        }
581
582        if due.is_empty() {
583            return;
584        }
585
586        let neighbors = self.transport_manager.mesh_neighbors();
587
588        // `relay` is borrowed rather than destructured throughout: a frame that
589        // reaches no neighbor has to be handed back whole, and cloning the
590        // message to keep that option open would copy the payload — up to a
591        // whole media chunk — on every flush.
592        for relay in due {
593            let message = &relay.message;
594            let recipient = message.recipient.as_str().to_string();
595            let message_id = message.id.as_str();
596            let hop_count = message.hop_count.value();
597            let remaining_ttl = message.ttl.value();
598            let held = relay.custody_target.is_some();
599            let cause = if held {
600                "custody_redelivery"
601            } else {
602                "forward"
603            };
604
605            let targets = if let Some(target) = relay.custody_target.as_deref() {
606                // A held frame goes to the one neighbor it was queued toward,
607                // never through target selection: a fan-out here would defeat
608                // at most once per neighbor. Gone from range, it goes nowhere
609                // and back to the store below.
610                if neighbors.iter().any(|n| n.peer_id == target) {
611                    vec![target.to_string()]
612                } else {
613                    Vec::new()
614                }
615            } else {
616                let mut exclude: Vec<&str> = vec![message.sender.as_str()];
617                if let Some(peer) = relay.arrival_peer.as_deref() {
618                    exclude.push(peer);
619                }
620                // Neighbors that handed us a copy while this waited hold the
621                // frame already (`MeshRelayGovernor::admit`).
622                exclude.extend(relay.heard_from.iter().map(String::as_str));
623                let onward = self.mesh_relay.select_targets(
624                    neighbors
625                        .iter()
626                        .map(|n| (n.peer_id.as_str(), n.link_quality())),
627                    &exclude,
628                    &message_id,
629                );
630
631                // If the destination is one of our own neighbors, hand it over
632                // directly: no fan-out is worth more than arriving. Should that
633                // link fail between choosing it and writing to it, fall back to
634                // carrying it onward rather than dropping a frame we could still
635                // move.
636                if neighbors.iter().any(|n| n.peer_id == recipient) {
637                    let mut ordered = vec![recipient.clone()];
638                    ordered.extend(onward.into_iter().filter(|peer| peer != &recipient));
639                    ordered
640                } else {
641                    onward
642                }
643            };
644            let deliver_direct = targets.first() == Some(&recipient);
645
646            // Every neighbor we could hand it to has handed it to us. This is
647            // the one way a duplicate ends a forward: the frame is not lost,
648            // it is held all around us, and requeueing it would only spend
649            // the next ticks finding the same answer.
650            if targets.is_empty()
651                && !held
652                && !relay.heard_from.is_empty()
653                && neighbors
654                    .iter()
655                    .any(|n| relay.heard_from.iter().any(|peer| peer == &n.peer_id))
656            {
657                debug!(
658                    message_id = %message.id,
659                    "Every onward neighbor already holds this frame"
660                );
661                self.mesh_relay.drop_covered(relay);
662                continue;
663            }
664
665            if targets.is_empty() {
666                // Nowhere to hand it right now: we hold no link, or the only
667                // ones we hold are the peers this frame must not go back to.
668                // Falls through to the hand-back below rather than being
669                // dropped — a neighbor may well appear within the few seconds
670                // the frame is still worth carrying.
671                debug!(
672                    message_id = %message.id,
673                    cause,
674                    "No onward neighbor for this frame"
675                );
676            }
677
678            let mut delivered_to = 0usize;
679            let mut reached_recipient = false;
680            for target in &targets {
681                // Each link this frame crosses is one transmission against the
682                // device's ceiling. Running out mid-fan-out stops the fan-out
683                // rather than the frame: the neighbors already reached carry it
684                // on, and the sender's retry covers the rest.
685                if !self.mesh_relay.take_send_token() {
686                    debug!(
687                        message_id = %message.id,
688                        "At the forwarding limit; stopping this fan-out here"
689                    );
690                    break;
691                }
692
693                match self.transport_manager.send_to_neighbor(target, message) {
694                    Ok(transport) => {
695                        delivered_to += 1;
696                        // Handing it to the recipient themselves ends the
697                        // journey; the remaining neighbors are only a fallback
698                        // for that link failing.
699                        if deliver_direct && target == &recipient {
700                            reached_recipient = true;
701                            debug!(
702                                message_id = %message.id,
703                                next_hop = %target,
704                                transport = ?transport,
705                                cause,
706                                "Delivered to its recipient directly"
707                            );
708                            break;
709                        }
710                        debug!(
711                            message_id = %message.id,
712                            next_hop = %target,
713                            transport = ?transport,
714                            hop_count,
715                            remaining_ttl,
716                            cause,
717                            "Forwarded frame to neighbor"
718                        );
719                    }
720                    Err(err) => {
721                        // A link that went away between selection and send.
722                        // The other targets still carry the frame.
723                        debug!(
724                            message_id = %message.id,
725                            next_hop = %target,
726                            error = %err,
727                            "Could not forward to neighbor"
728                        );
729                    }
730                }
731            }
732
733            // Nothing reached a neighbor. Hand it back instead of dropping it:
734            // its id is already recorded as handled here, so a drop would lose
735            // this copy *and* refuse both the copies arriving behind it and the
736            // sender's retransmissions, for the whole retention window.
737            //
738            // Why it got nowhere does not change that. The budget ran out (the
739            // frames released alongside this one spent the allowance between
740            // them), every link chosen for it went away between being picked
741            // and being written to, or there was no usable link to pick. In all
742            // three the frame has travelled nowhere and this queue is the only
743            // thing that still remembers it. It keeps its due time, so one that
744            // stays stuck is abandoned by the overdue cut-off rather than
745            // retried forever.
746            //
747            // A held frame refused room on the way back goes to the custody
748            // store instead: the store still holds it, nothing is lost, and
749            // its id was never recorded here.
750            if delivered_to == 0 {
751                if let Some(returned) = self.mesh_relay.requeue(relay) {
752                    self.return_held_to_store(returned);
753                }
754                continue;
755            }
756
757            if held {
758                self.record_custody_transmission(&relay, reached_recipient);
759            }
760            self.mesh_relay.record_forwarded();
761            self.emit_event(Event::message_relayed(
762                message.id.as_str(),
763                message.sender.as_str().to_string(),
764                recipient,
765                hop_count,
766                remaining_ttl,
767            ));
768        }
769    }
770
771    /// [`Self::handle_incoming_file_chunk_via`] with no known arrival transport
772    /// (a deferred chunk falls back to the resend-driven ACK). Used only by
773    /// tests; production always routes through the `_via` form with the inbound
774    /// transport.
775    #[cfg(test)]
776    pub(super) fn handle_incoming_file_chunk(&mut self, message: &Message) -> ChunkOutcome {
777        self.handle_incoming_file_chunk_via(message, None)
778    }
779
780    /// Routes an inbound file-chunk message through the transfer manager,
781    /// recording `arrival_transport` on the pending entry if the chunk must be
782    /// deferred, so the drain can ACK it directly once the session confirms.
783    pub(super) fn handle_incoming_file_chunk_via(
784        &mut self,
785        message: &Message,
786        arrival_transport: Option<TransportType>,
787    ) -> ChunkOutcome {
788        let sender = message.sender.as_str().to_string();
789
790        // SEC-H1: encrypted chunks carry the chunk bytes, media metadata, and
791        // original content type inside the MLS ciphertext; legacy plaintext
792        // chunks carry them on the wire Message and are accepted only where
793        // policy allows. Rich extras (caption, reply, forward) only ever come
794        // from the sealed plaintext — never from the wire Message.
795        let (chunk, media_metadata, original_content_type, rich_extras, data_purpose) =
796            if let Some(ref binary) = message.binary_content {
797                if is_media_envelope(binary) {
798                    let encrypted = match decode_media_envelope(binary) {
799                        Ok(e) => e,
800                        // A frame whose magic byte still reads as a media envelope
801                        // but whose encoding does not decode — in-transit
802                        // corruption past the magic, or injected garbage. Under
803                        // crypto recovery this is the pre-decrypt sibling of a hard
804                        // decrypt failure: the frame as it stands is dead, but the
805                        // sender's resend carries a fresh encoding that would
806                        // decode, so withhold the ACK (`Deferred`) instead of
807                        // telling the sender "delivered" for a chunk we dropped.
808                        // Do NOT enqueue: an unparseable frame can never become
809                        // parseable, so a queued copy could never drain — the same
810                        // reasoning as the spent-generation arm below.
811                        Err(e) => {
812                            warn!(
813                                message_id = %message.id,
814                                error = %e,
815                                "Failed to decode encrypted media envelope"
816                            );
817                            if !self.config.encryption.crypto_recovery_enabled {
818                                return ChunkOutcome::Handled;
819                            }
820                            // Advisory, not terminal — the chunk was never ACKed, so
821                            // the transfer is *stalled* pending a resend rather than
822                            // failed. The terminal media signal stays
823                            // `FileReceiveFailed`.
824                            if let Ok(state) = lock_shared_state(&self.shared_state) {
825                                state.emit_event(Event::message_decryption_failed(
826                                message.id.clone(),
827                                sender.clone(),
828                                crate::events::DecryptionFailureCode::InvalidPayload,
829                                "encrypted media envelope failed to decode; its file transfer is stalled until the sender resends".to_string(),
830                            ));
831                            }
832                            return ChunkOutcome::Deferred;
833                        }
834                    };
835                    let plaintext = match self.decrypt_media_chunk(
836                        &sender,
837                        &encrypted,
838                        message,
839                        arrival_transport,
840                    ) {
841                        MediaChunkDecrypt::Plaintext(p) => p,
842                        // Not delivered and recoverable by a resend (queued for
843                        // delayed decryption, or an undecryptable frame the
844                        // sender can re-send): defer the ACK so the sender
845                        // retries and the resend re-enters processing.
846                        MediaChunkDecrypt::Deferred => return ChunkOutcome::Deferred,
847                        // Illegitimate frame (foreign session slot, or an MLS
848                        // credential naming a different sender): answer with
849                        // silence, exactly like the text `SecurityRejected`
850                        // path. ACKing here would tell an injector that the
851                        // target is online and processing their frames — the
852                        // one thing the text path withholds.
853                        MediaChunkDecrypt::SecurityRejected => return ChunkOutcome::Rejected,
854                        // Terminal drop (permanent refusal, empty plaintext, MLS
855                        // unavailable): ACK as before — the sender cannot
856                        // recover it by retrying.
857                        MediaChunkDecrypt::Dropped => return ChunkOutcome::Handled,
858                    };
859                    let inner = match MediaChunkPlaintext::decode(&plaintext) {
860                        Ok(i) => i,
861                        Err(e) => {
862                            warn!(
863                                message_id = %message.id,
864                                error = %e,
865                                "Failed to parse decrypted media chunk plaintext, dropping"
866                            );
867                            return ChunkOutcome::Handled;
868                        }
869                    };
870                    let chunk = match FileChunk::from_bytes(&inner.chunk_bytes) {
871                        Ok(c) => c,
872                        Err(e) => {
873                            warn!(
874                                message_id = %message.id,
875                                error = %e,
876                                "Failed to deserialize decrypted file chunk, dropping"
877                            );
878                            return ChunkOutcome::Handled;
879                        }
880                    };
881                    (
882                        chunk,
883                        inner.media_metadata,
884                        inner.original_content_type,
885                        inner.rich_extras,
886                        inner.data_purpose,
887                    )
888                } else {
889                    if !self.accept_plaintext_content(&sender) {
890                        warn!(
891                            message_id = %message.id,
892                            sender = %sender,
893                            "Rejecting unencrypted media chunk (encryption policy)"
894                        );
895                        self.warn_plaintext_receive_rejected(
896                            &sender,
897                            "Inbound plaintext media chunk rejected by encryption policy",
898                        );
899                        return ChunkOutcome::Rejected;
900                    }
901                    let chunk = match FileChunk::from_bytes(binary) {
902                        Ok(c) => c,
903                        Err(e) => {
904                            warn!(
905                                message_id = %message.id,
906                                error = %e,
907                                "Failed to deserialize binary file chunk, dropping"
908                            );
909                            return ChunkOutcome::Handled;
910                        }
911                    };
912                    let (meta, oct) = Self::wire_media_metadata(message);
913                    (chunk, meta, oct, None, None)
914                }
915            } else {
916                if !self.accept_plaintext_content(&sender) {
917                    warn!(
918                        message_id = %message.id,
919                        sender = %sender,
920                        "Rejecting unencrypted media chunk (encryption policy)"
921                    );
922                    self.warn_plaintext_receive_rejected(
923                        &sender,
924                        "Inbound plaintext media chunk rejected by encryption policy",
925                    );
926                    return ChunkOutcome::Rejected;
927                }
928                let chunk = match FileChunk::from_json(&message.content) {
929                    Ok(c) => c,
930                    Err(e) => {
931                        warn!(
932                            message_id = %message.id,
933                            error = %e,
934                            "Failed to deserialize file chunk, dropping"
935                        );
936                        return ChunkOutcome::Handled;
937                    }
938                };
939                let (meta, oct) = Self::wire_media_metadata(message);
940                (chunk, meta, oct, None, None)
941            };
942
943        let file_id = chunk.file_id.clone();
944        let file_name = chunk.file_name.clone();
945        let file_size = chunk.file_size;
946        let is_first_chunk = chunk.chunk_index == 0;
947
948        // A duplicate chunk 0 must not reclassify a transfer already under
949        // way. Duplicates are accepted by replacement, and the manager's
950        // consistency check covers only the fields on the chunk itself: the
951        // purpose rides the sealed envelope, outside them. Without this an
952        // authenticated peer sends chunk 0 twice, once marked and once not,
953        // and chooses afterwards which side of the invisibility rule the
954        // transfer lands on. Either direction is a defect the marking
955        // exists to prevent: a document snapshot surfaced to a person as a
956        // downloaded file, or an app's own download turned invisible and
957        // left with no terminal event ever.
958        //
959        // Scoped to the sender the assembly belongs to. A chunk under
960        // another peer's file_id is refused by the manager's own sender
961        // check, and refusing it here instead would let any peer tombstone
962        // a transfer it merely named.
963        if is_first_chunk
964            && self
965                .pending_media_metadata
966                .get(&file_id)
967                .is_some_and(|entry| entry.sender == sender && entry.data_purpose != data_purpose)
968        {
969            warn!(
970                file_id = %file_id,
971                peer = %sender,
972                "Refusing a chunk 0 that reclassifies a transfer already under way"
973            );
974            self.file_transfer_manager.refuse_transfer(&file_id);
975            self.pending_media_metadata.remove(&file_id);
976            return ChunkOutcome::Handled;
977        }
978
979        // Refuse a blob nobody asked for at the door, not at the end.
980        //
981        // The check also runs on completion, which is where the hash is
982        // verified, but by then the whole transfer has been buffered,
983        // reassembled and checksummed. That is the storage and the battery
984        // this rule exists to protect: an authenticated peer could otherwise
985        // open as many transfers as the resource caps allow, hold them for
986        // the stale timeout, and pay one frame per transfer to do it.
987        //
988        // Chunk 0 carries the purpose, so this is where the question can
989        // first be asked. Answered with an ACK rather than silence: the
990        // sender is wrong rather than unlucky, and a retry would be refused
991        // exactly the same way.
992        if is_first_chunk
993            && !self.admits_data_media_transfer(&sender, data_purpose.as_ref(), file_size)
994        {
995            // Refused, not merely skipped. Chunks need not arrive in order,
996            // so any that landed before this one already opened an assembly,
997            // and any behind it would open another. The refusal has to
998            // outlive the chunk that prompted it, which is what the
999            // tombstone is for.
1000            self.file_transfer_manager.refuse_transfer(&file_id);
1001            self.pending_media_metadata.remove(&file_id);
1002            return ChunkOutcome::Handled;
1003        }
1004
1005        // Metadata is recorded only for chunks the manager accepts — a
1006        // rejected chunk must leave no state behind (SEC-H2).
1007        match self.file_transfer_manager.process_chunk(&sender, chunk) {
1008            Ok(progress) => {
1009                if is_first_chunk {
1010                    self.pending_media_metadata.insert(
1011                        file_id.clone(),
1012                        PendingMediaMetadataEntry {
1013                            content_type: original_content_type.unwrap_or(ContentType::File),
1014                            media_metadata,
1015                            last_updated_at: Instant::now(),
1016                            sender: sender.clone(),
1017                            rich_extras,
1018                            timestamp_ms: message.timestamp.as_millis(),
1019                            app_id: message.app_id.as_str().to_string(),
1020                            data_purpose,
1021                        },
1022                    );
1023                } else if let Some(entry) = self.pending_media_metadata.get_mut(&file_id) {
1024                    entry.last_updated_at = Instant::now();
1025                }
1026                // What this transfer is, as far as this device can tell
1027                // right now. Read once and used twice below.
1028                let arriving = self
1029                    .pending_media_metadata
1030                    .get(&file_id)
1031                    .and_then(|entry| entry.data_purpose.clone());
1032                // A chunk landing is the sign that an answer is coming, so
1033                // the question it answers is kept alive. The fetch record is
1034                // what admits the bytes at the end, and it expires on a
1035                // clock that a large blob over a slow radio outlives: without
1036                // this the record dies mid-carriage and the completed
1037                // transfer is then dropped as unsolicited, forever, because
1038                // every retry takes just as long.
1039                if let Some(purpose) = &arriving {
1040                    self.note_data_media_progress(&sender, purpose);
1041                }
1042                // A data-purposed transfer is invisible to the application
1043                // for its whole life, not merely at the end. Reporting
1044                // progress on it would put a download nobody started in
1045                // front of a person, counting up to a file that never
1046                // appears.
1047                //
1048                // Positive knowledge, not absence of it: the purpose rides
1049                // chunk 0, so a chunk arriving before it leaves this device
1050                // unable to say what the transfer is. Reading "no entry yet"
1051                // as "not data-purposed" would emit exactly the phantom
1052                // progress this suppression exists to prevent, on any
1053                // transport that delivers chunk 1 first.
1054                //
1055                // The cost is that an ordinary transfer whose chunk 0 is
1056                // delayed loses the progress events until it lands. That is
1057                // cheap: progress is advisory and each event supersedes the
1058                // last, so the app sees the count resume rather than a gap.
1059                let reports_progress =
1060                    arriving.is_none() && self.pending_media_metadata.contains_key(&file_id);
1061                if reports_progress {
1062                    if let Ok(state) = lock_shared_state(&self.shared_state) {
1063                        state.emit_event(Event::file_progress(
1064                            file_id.clone(),
1065                            progress.chunks_completed,
1066                            progress.total_chunks,
1067                        ));
1068                    }
1069                }
1070            }
1071            Err(rejection) if rejection.is_resource_exhaustion() => {
1072                // A well-formed transfer was dropped by a receiver-side
1073                // resource limit. The chunk was already ACKed and will not
1074                // be retransmitted, so the transfer is unrecoverable —
1075                // surface that to the application instead of going silent.
1076                let dropped = self.pending_media_metadata.remove(&file_id);
1077                // Positive knowledge, the same rule the progress event
1078                // follows, reached by a different road. On chunk 0 the
1079                // purpose is in hand right here and the stored entry is not:
1080                // an entry is written only once the manager accepts a chunk,
1081                // which is exactly what did not happen. Off chunk 0 the
1082                // stored entry is the only knowledge there is.
1083                let identified = is_first_chunk || dropped.is_some();
1084                let purpose = if is_first_chunk { data_purpose } else { None }
1085                    .or_else(|| dropped.and_then(|entry| entry.data_purpose));
1086                if let Some(purpose) = purpose {
1087                    self.report_data_media_transfer_failure(&sender, &purpose, rejection.as_str());
1088                    return ChunkOutcome::Handled;
1089                }
1090                if !identified {
1091                    // A transfer whose chunk 0 has not landed cannot be named
1092                    // as anything, so it must not be failed in front of a
1093                    // person: it may be the document layer's. The app has
1094                    // heard nothing about it either (progress is withheld by
1095                    // the same rule), so there is no report to correct.
1096                    debug!(
1097                        file_id = %file_id,
1098                        "Dropping a transfer whose purpose is not yet known; no failure reported"
1099                    );
1100                    return ChunkOutcome::Handled;
1101                }
1102                if let Ok(state) = lock_shared_state(&self.shared_state) {
1103                    state.emit_event(Event::file_receive_failed(
1104                        file_id,
1105                        file_name,
1106                        sender,
1107                        rejection.as_str().to_string(),
1108                    ));
1109                }
1110                return ChunkOutcome::Handled;
1111            }
1112            // Malformed or mismatched chunks are logged by the manager; the
1113            // assembly they targeted (if any) stays intact. Chunks of an
1114            // already-failed transfer land here too (`previously_failed`) —
1115            // its FileReceiveFailed event already fired exactly once.
1116            Err(_) => return ChunkOutcome::Handled,
1117        }
1118
1119        if self.file_transfer_manager.is_complete(&file_id) {
1120            let Some(file_data) = self.file_transfer_manager.finalize_file(&file_id) else {
1121                warn!(
1122                    file_id = %file_id,
1123                    "File transfer marked complete but reassembly or integrity checks failed"
1124                );
1125                let dropped = self.pending_media_metadata.remove(&file_id);
1126                // Completion implies chunk 0 was accepted, so this is
1127                // normally `true`. Checked anyway, and for the same reason as
1128                // the arm above: the alternative is naming a transfer this
1129                // device cannot identify as a file the app never saw begin.
1130                let identified = dropped.is_some();
1131                if let Some(purpose) = dropped.and_then(|entry| entry.data_purpose) {
1132                    self.report_data_media_transfer_failure(
1133                        &sender,
1134                        &purpose,
1135                        "integrity_check_failed",
1136                    );
1137                    return ChunkOutcome::Handled;
1138                }
1139                if !identified {
1140                    debug!(
1141                        file_id = %file_id,
1142                        "Dropping a transfer whose purpose is not yet known; no failure reported"
1143                    );
1144                    return ChunkOutcome::Handled;
1145                }
1146                if let Ok(state) = lock_shared_state(&self.shared_state) {
1147                    state.emit_event(Event::file_receive_failed(
1148                        file_id,
1149                        file_name,
1150                        sender,
1151                        "integrity_check_failed".to_string(),
1152                    ));
1153                }
1154                return ChunkOutcome::Handled;
1155            };
1156            let metadata_entry = self.pending_media_metadata.remove(&file_id);
1157            // The one branch that makes a data-purposed transfer worth
1158            // having: the bytes go to the document layer and the person is
1159            // told nothing, because nothing happened that concerns them.
1160            if let Some(purpose) = metadata_entry
1161                .as_ref()
1162                .and_then(|entry| entry.data_purpose.clone())
1163            {
1164                self.route_data_media_payload(&sender, &purpose, file_data);
1165                return ChunkOutcome::Handled;
1166            }
1167            let (content_type, media_metadata, rich_extras, timestamp_ms, app_id) = metadata_entry
1168                .map(|entry| {
1169                    (
1170                        entry.content_type,
1171                        entry.media_metadata,
1172                        entry.rich_extras,
1173                        Some(entry.timestamp_ms),
1174                        Some(entry.app_id),
1175                    )
1176                })
1177                .unwrap_or((ContentType::File, None, None, None, None));
1178            let rich_extras = rich_extras.unwrap_or_default();
1179
1180            if let Ok(state) = lock_shared_state(&self.shared_state) {
1181                state.emit_event(Event::file_received(
1182                    file_id,
1183                    file_name,
1184                    file_size,
1185                    sender,
1186                    content_type,
1187                    media_metadata,
1188                    file_data,
1189                    timestamp_ms,
1190                    rich_extras.caption,
1191                    rich_extras.reply_to_msg,
1192                    rich_extras.reply_context.as_ref(),
1193                    rich_extras.forward_info.as_ref(),
1194                    app_id,
1195                ));
1196            }
1197        }
1198
1199        ChunkOutcome::Handled
1200    }
1201
1202    /// Whether a data-purposed transfer may be admitted at all.
1203    ///
1204    /// Only the attachment case is answerable by the fetch record: an
1205    /// attachment is bytes this device asked a named peer for, so the request
1206    /// either exists or it does not. A snapshot is unsolicited by design (it
1207    /// answers a version exchange rather than a fetch) and is bounded instead
1208    /// by the size check and the containment on the import path.
1209    ///
1210    /// The runtime kill switch is checked ahead of both, and what it saves is
1211    /// the buffering rather than the import. A device with the layer off was
1212    /// never going to write anything: `require_data_storage` fails closed
1213    /// with `DataDisabled` further down. But a transfer admitted at chunk 0
1214    /// is reassembled in full before it reaches that seam, so without this
1215    /// gate a device that opted out still holds a peer's whole blob in memory
1216    /// on its way to a refusal that was certain from the start.
1217    ///
1218    /// A conforming peer never sends one, because a build with the layer off
1219    /// never advertises the capability that permits it. This is the arm for
1220    /// the peer whose knowledge of us is stale or wrong.
1221    fn admits_data_media_transfer(
1222        &self,
1223        sender: &str,
1224        purpose: Option<&crate::media_envelope::DataPurpose>,
1225        declared_size: u64,
1226    ) -> bool {
1227        let Some(purpose) = purpose else {
1228            return true;
1229        };
1230        #[cfg(feature = "data")]
1231        {
1232            if !self.config.data.enabled {
1233                warn!(
1234                    peer = %sender,
1235                    "Refusing a document-layer transfer: the data layer is off"
1236                );
1237                return false;
1238            }
1239            match purpose {
1240                crate::media_envelope::DataPurpose::Attachment { hash } => {
1241                    if !self.awaiting_attachment(sender, hash) {
1242                        warn!(
1243                            peer = %sender,
1244                            "Refusing attachment bytes for a fetch this device never made"
1245                        );
1246                        return false;
1247                    }
1248                }
1249                // A snapshot has no fetch record to judge it by, so the size
1250                // it declares is the only thing that can be judged here. It
1251                // is judged here rather than at the end because the ceiling
1252                // is certain: a document past it cannot be persisted by this
1253                // device even if every byte arrives, so admitting one spends
1254                // a whole reassembly to reach a refusal that was decided
1255                // before the first chunk. The completion check stays as the
1256                // backstop against a header that lied.
1257                crate::media_envelope::DataPurpose::Snapshot { doc } => {
1258                    // The same refusal the import path makes, made before
1259                    // the reassembly rather than after it, and for the same
1260                    // reason the size check is here: the answer is already
1261                    // certain. A document outside this space's interest is
1262                    // refused whatever arrives, so admitting one spends a
1263                    // whole transfer, up to the record ceiling, to reach a
1264                    // decision that was made when the application narrowed
1265                    // the space. Only a peer without entry 5 sends one,
1266                    // which is exactly the peer whose traffic the declared
1267                    // interest could not save.
1268                    if !self.data_wants(sender, doc) {
1269                        debug!(
1270                            peer = %sender,
1271                            doc,
1272                            "Refusing a carried snapshot for a document outside this space's interest"
1273                        );
1274                        return false;
1275                    }
1276                    if declared_size > crate::protocol::data_sync::MAX_MEDIA_SNAPSHOT_BYTES as u64 {
1277                        warn!(
1278                            peer = %sender,
1279                            declared = declared_size,
1280                            "Refusing a carried snapshot larger than a record can hold"
1281                        );
1282                        return false;
1283                    }
1284                }
1285            }
1286            true
1287        }
1288        #[cfg(not(feature = "data"))]
1289        {
1290            let _ = (sender, purpose, declared_size);
1291            // Nothing here can use these bytes, so nothing here should buffer
1292            // them either.
1293            false
1294        }
1295    }
1296
1297    /// Hand a completed data-purposed transfer to the document layer.
1298    ///
1299    /// Split behind a feature gate because the layer is optional and the
1300    /// bytes are not: a build compiled without it still has to recognise
1301    /// such a transfer, since recognising it is what keeps a document
1302    /// snapshot from being handed to a person as a downloaded file. It has
1303    /// nowhere to put the bytes, so it drops them. A conforming peer never
1304    /// sends one, because a build without the layer never advertises the
1305    /// capability that permits it.
1306    pub(super) fn route_data_media_payload(
1307        &mut self,
1308        sender: &str,
1309        purpose: &crate::media_envelope::DataPurpose,
1310        bytes: Vec<u8>,
1311    ) {
1312        #[cfg(feature = "data")]
1313        {
1314            self.accept_data_media_payload(sender, purpose, bytes);
1315        }
1316        #[cfg(not(feature = "data"))]
1317        {
1318            let _ = bytes;
1319            warn!(
1320                peer = %sender,
1321                ?purpose,
1322                "Dropping a document-layer transfer: this build has no data layer"
1323            );
1324        }
1325    }
1326
1327    /// Note that a data-purposed transfer is still arriving, so whatever
1328    /// this device is waiting on does not expire underneath it. See
1329    /// [`Self::route_data_media_payload`] for the gate.
1330    pub(super) fn note_data_media_progress(
1331        &mut self,
1332        sender: &str,
1333        purpose: &crate::media_envelope::DataPurpose,
1334    ) {
1335        #[cfg(feature = "data")]
1336        {
1337            self.refresh_attachment_fetch(sender, purpose);
1338        }
1339        #[cfg(not(feature = "data"))]
1340        {
1341            // A build with no data layer never admitted this transfer, so
1342            // there is nothing here that could be waiting on it.
1343            let _ = (sender, purpose);
1344        }
1345    }
1346
1347    /// Report a failed data-purposed transfer, where there is anything to
1348    /// report it to. See [`Self::route_data_media_payload`] for the gate.
1349    pub(super) fn report_data_media_transfer_failure(
1350        &mut self,
1351        sender: &str,
1352        purpose: &crate::media_envelope::DataPurpose,
1353        reason: &str,
1354    ) {
1355        #[cfg(feature = "data")]
1356        {
1357            self.report_data_media_failure(sender, purpose, reason);
1358        }
1359        #[cfg(not(feature = "data"))]
1360        {
1361            debug!(
1362                peer = %sender,
1363                ?purpose,
1364                reason,
1365                "A document-layer transfer failed; this build has no data layer"
1366            );
1367        }
1368    }
1369
1370    /// Extracts the chunk-0 metadata a legacy (unencrypted) chunk carries on
1371    /// the wire `Message`.
1372    fn wire_media_metadata(message: &Message) -> (Option<MediaMetadata>, Option<ContentType>) {
1373        use crate::constants::ORIGINAL_CONTENT_TYPE_KEY;
1374        let original_ct = message
1375            .metadata
1376            .get(ORIGINAL_CONTENT_TYPE_KEY)
1377            .map(|s| ContentType::parse(s));
1378        (message.media_metadata.clone(), original_ct)
1379    }
1380
1381    /// Policy gate for inbound plaintext content — text messages and legacy
1382    /// (unencrypted) media chunks alike: rejected when this node requires
1383    /// encryption, and rejected once the sender is known to run MLS — an
1384    /// encryption-capable peer sending plaintext is a downgrade/forgery attempt
1385    /// (plaintext carries no sender authentication, so anyone could inject it
1386    /// under a contact's name).
1387    ///
1388    /// # Capability, not confirmation
1389    ///
1390    /// This asks whether the peer *can* encrypt, not whether a session is
1391    /// confirmed. The distinction is the whole gate: a sender only ever emits
1392    /// plaintext when its own `should_auto_encrypt()` is false, which precludes
1393    /// it having established a session with us — while the session is merely
1394    /// pending it *queues* (`prepare_outbound_content`) rather than downgrading.
1395    /// So no honest peer sends cleartext while we know it speaks MLS, and
1396    /// asking only about *confirmed* sessions left a hole: an attacker with
1397    /// app-container write access deletes the `session_states` record, restore
1398    /// re-bootstraps it as `Pending` (which it must), the peer silently drops
1399    /// out of `confirmed_sessions`, and this gate re-opens. See
1400    /// [`OfflineProtocol::encryption_capable_peers`] for why that signal now
1401    /// comes from the credential store instead.
1402    ///
1403    /// The capability check is also *cheaper* than the confirmation one it
1404    /// precedes: it is an in-memory set lookup, where `is_session_confirmed`
1405    /// falls through to a protocol-state read plus a sealed-record open for any
1406    /// sender not already in `confirmed_sessions` — a path driven by the
1407    /// attacker-controlled `message.sender`. Ordering the cheap check first
1408    /// makes a flood of forged sender ids cost less than it used to.
1409    ///
1410    /// The group path has gated on existence rather than confirmation since it
1411    /// was written (`has_mls_group_state`); this brings the 1:1 path into line.
1412    fn accept_plaintext_content(&mut self, sender: &str) -> bool {
1413        if self.config.encryption.require_encryption {
1414            return false;
1415        }
1416        // `should_auto_encrypt()` guards both checks: a node with encryption
1417        // disabled or MLS uninitialized has no standing to call anything a
1418        // downgrade, and removing this guard would reject legitimate plaintext
1419        // on every plaintext-only deployment.
1420        if !self.should_auto_encrypt() {
1421            return true;
1422        }
1423        if self.is_encryption_capable(sender) {
1424            return false;
1425        }
1426        // Fail closed: if the confirmation lookup errors (storage failure),
1427        // treat the session as confirmed and reject — accepting plaintext on
1428        // error would let a storage fault disable the downgrade gate.
1429        if self.is_session_confirmed(sender).unwrap_or(true) {
1430            return false;
1431        }
1432        true
1433    }
1434
1435    /// Decrypts an encrypted media chunk envelope. Four failure dispositions,
1436    /// mirroring the text path in [`Self::handle_encrypted_message`]:
1437    ///
1438    /// - **Session not ready**: the whole message is queued for delayed
1439    ///   decryption and [`MediaChunkDecrypt::Deferred`] is returned.
1440    /// - **Recoverable** (epoch desync, or a crypto/transport failure while
1441    ///   `crypto_recovery_enabled`): [`MediaChunkDecrypt::Deferred`] *without*
1442    ///   queueing — the ciphertext is dead either way, so recovery is the
1443    ///   sender's resend, driven by the withheld ACK.
1444    /// - **Security rejection** (the envelope names another pair's session
1445    ///   slot, or the MLS credential authenticates a different sender than the
1446    ///   wire envelope claims): [`MediaChunkDecrypt::SecurityRejected`], which
1447    ///   the caller answers with silence. Deliberately *not* gated on
1448    ///   `crypto_recovery_enabled` — this is about what the receiver reveals,
1449    ///   not about recovery, and the text path's equivalent is unconditional.
1450    /// - **Terminal** (a permanent refusal, an empty plaintext, or any crypto
1451    ///   failure with recovery switched off): decryption telemetry plus
1452    ///   [`MediaChunkDecrypt::Dropped`], which the caller still ACKs.
1453    ///
1454    /// On `Deferred` or `SecurityRejected` the caller must skip the ACK and
1455    /// unmark the id — for the former so the sender keeps retrying, for the
1456    /// latter so a replay re-enters this gate rather than hitting the duplicate
1457    /// re-ACK path. A successful decrypt doubles as a session confirmation
1458    /// signal, exactly like text decrypts.
1459    fn decrypt_media_chunk(
1460        &mut self,
1461        sender: &str,
1462        encrypted: &EncryptedMessage,
1463        message: &Message,
1464        arrival_transport: Option<TransportType>,
1465    ) -> MediaChunkDecrypt {
1466        let group_id = encrypted.group_id.as_str().to_string();
1467
1468        // Media envelopes are only ever produced for the sender's 1:1 session,
1469        // whose MLS group id is deterministic. Enforce that binding before
1470        // decrypting: MLS authenticates group membership, not the wire sender
1471        // claim, so without this check any peer holding a valid session with
1472        // us could deliver its own ciphertext under an arbitrary
1473        // `message.sender` and have the file attributed to that identity.
1474        let Ok(expected_group) = GroupId::for_session(&self.local_id, sender) else {
1475            warn!(
1476                sender = %sender,
1477                "Cannot derive 1:1 session id for media chunk sender, dropping"
1478            );
1479            return MediaChunkDecrypt::Dropped;
1480        };
1481        if encrypted.group_id != expected_group {
1482            error!(
1483                sender = %sender,
1484                group_id = %group_id,
1485                expected = %expected_group,
1486                "SECURITY: encrypted media chunk MLS group does not match the claimed sender's session, rejecting"
1487            );
1488            self.emit_security_warning(
1489                sender,
1490                SecurityWarningCode::MediaSenderGroupMismatch,
1491                "Encrypted media chunk MLS group does not match the claimed sender",
1492            );
1493            // Not ACKed: the text path answers the identical condition
1494            // (`MlsError::SessionIdentityMismatch` →
1495            // `InternalMessageResult::SecurityRejected`) with silence, and an
1496            // ACK here would leak exactly what that silence protects — an
1497            // injector who gets an ACK for a media chunk but nothing for the
1498            // same text frame learns the target is live either way.
1499            return MediaChunkDecrypt::SecurityRejected;
1500        }
1501
1502        let Some(mls) = self.mls_manager.clone() else {
1503            warn!(
1504                sender = %sender,
1505                "Encrypted media chunk received but MLS is not initialized, dropping"
1506            );
1507            self.emit_mls_decryption_failed(
1508                sender,
1509                Some(&group_id),
1510                DecryptionFailureKind::NotInitialized,
1511                MlsOperationContext::Receive,
1512            );
1513            return MediaChunkDecrypt::Dropped;
1514        };
1515
1516        let decrypt_result = {
1517            let manager = match mls.read() {
1518                Ok(m) => m,
1519                Err(_) => {
1520                    error!("MLS lock poisoned while decrypting media chunk");
1521                    return MediaChunkDecrypt::Dropped;
1522                }
1523            };
1524            manager.decrypt(encrypted, sender)
1525        };
1526
1527        match decrypt_result {
1528            Ok(Some(plaintext)) => {
1529                // A group-aware decrypt is the same confirmation signal the
1530                // text path uses; skip when already cache-confirmed to avoid
1531                // per-chunk storage I/O.
1532                if !self.confirmed_sessions.contains(sender) {
1533                    self.confirm_session_from_successful_decrypt(sender, &group_id);
1534                }
1535                MediaChunkDecrypt::Plaintext(plaintext)
1536            }
1537            Ok(None) => {
1538                warn!(sender = %sender, "Media chunk decryption returned empty, dropping");
1539                MediaChunkDecrypt::Dropped
1540            }
1541            // Intercepted BEFORE classification, mirroring the text path in
1542            // `handle_encrypted_message`. Both variants classify as
1543            // `SessionStateError::Unknown`, whose disposition must stay
1544            // terminal-drop-and-ACK for the refusals that genuinely belong
1545            // there (`CommitNotAuthorized`), so the split has to happen here
1546            // rather than in the classifier.
1547            Err(ref e) if is_media_security_rejection(e) => {
1548                error!(
1549                    sender = %sender,
1550                    error = %e,
1551                    "SECURITY: encrypted media chunk failed its sender/session identity check, rejecting"
1552                );
1553                MediaChunkDecrypt::SecurityRejected
1554            }
1555            Err(e) => {
1556                let classification = SessionStateError::from(&e);
1557                match classification {
1558                    SessionStateError::SessionNotReady | SessionStateError::GroupNotFound => {
1559                        info!(
1560                            sender = %sender,
1561                            error_code = classification.code(),
1562                            "Encrypted media chunk received before session ready, queuing"
1563                        );
1564                        self.emit_mls_session_missing(
1565                            Some(sender),
1566                            Some(&group_id),
1567                            MlsOperationContext::SessionLookup,
1568                            MlsErrorCategory::SessionStateMissing,
1569                        );
1570                        self.enqueue_pending_decryption_via(sender, message, arrival_transport);
1571                        MediaChunkDecrypt::Deferred
1572                    }
1573                    SessionStateError::SessionDesync
1574                        if self.config.encryption.crypto_recovery_enabled =>
1575                    {
1576                        // Epoch desync (see the text path in `handle_encrypted_message`):
1577                        // heal the channel with a rate-limited re-key and return
1578                        // Deferred so the chunk is not ACKed and its id is
1579                        // unmarked. We do NOT enqueue — the chunk is sealed to the
1580                        // dead epoch and can never decrypt after the re-key.
1581                        //
1582                        // Unlike a DM, an in-flight media chunk cannot be
1583                        // re-sealed for recovery: Tier 2 reseal is a deliberate
1584                        // no-op for media (the media outbox is not persisted and
1585                        // chunks are re-encoded, not replayed). Withholding the
1586                        // ACK here is what drives recovery: the sender's media
1587                        // outbox keeps retrying and, once its retry/ACK-timeout
1588                        // budget lapses, surfaces `MediaResendRequired`, and the
1589                        // app re-supplies the bytes via `send_media_with` — which
1590                        // re-encodes fresh chunks against the now-healed session.
1591                        // So media recovers via descriptor-based resend, not reseal.
1592                        info!(
1593                            sender = %sender,
1594                            error_code = classification.code(),
1595                            "Encrypted media chunk failed to decrypt due to epoch desync, re-keying"
1596                        );
1597                        self.schedule_session_rekey(sender);
1598                        MediaChunkDecrypt::Deferred
1599                    }
1600                    SessionStateError::TransportFailure | SessionStateError::CryptoFailure
1601                        if self.config.encryption.crypto_recovery_enabled =>
1602                    {
1603                        // A genuine crypto/transport failure on a chunk (AEAD
1604                        // failure, spent ratchet generation, malformed frame).
1605                        // Mirrors the text path in `handle_encrypted_message`:
1606                        // withhold the ACK and unmark the id rather than lying
1607                        // "delivered" for a chunk we dropped. Do NOT enqueue —
1608                        // the generation is spent, so a queued copy could never
1609                        // drain — and do NOT re-key, which stays desync-only.
1610                        //
1611                        // Recovery differs from a DM: chunks have no Tier 2
1612                        // re-seal (they are re-encoded, not replayed), so the
1613                        // un-ACKed chunk drives the media outbox's retry/ACK
1614                        // budget to lapse and surface `MediaResendRequired`,
1615                        // and the app re-supplies the bytes via `send_media_with`
1616                        // — re-encoded against a live generation. Same
1617                        // descriptor-based recovery as the media desync arm.
1618                        warn!(
1619                            sender = %sender,
1620                            error = %e,
1621                            error_code = classification.code(),
1622                            "Failed to decrypt media chunk; withholding ACK so the transfer can be resent"
1623                        );
1624                        let kind = DecryptionFailureKind::from_mls_error(&e);
1625                        self.emit_mls_decryption_failed(
1626                            sender,
1627                            Some(&group_id),
1628                            kind,
1629                            MlsOperationContext::Receive,
1630                        );
1631                        // Advisory, not terminal — matching a pending-queue
1632                        // eviction: the chunk was never ACKed, so the transfer
1633                        // is *stalled* pending a resend, not failed. The
1634                        // terminal media signal is still `FileReceiveFailed`.
1635                        if let Ok(state) = lock_shared_state(&self.shared_state) {
1636                            state.emit_event(Event::message_decryption_failed(
1637                                message.id.clone(),
1638                                sender.to_string(),
1639                                Self::decryption_failure_code_from_kind(kind),
1640                                format!(
1641                                    "encrypted media chunk failed to decrypt ({}); its file transfer is stalled until the sender resends",
1642                                    classification.code()
1643                                ),
1644                            ));
1645                        }
1646                        MediaChunkDecrypt::Deferred
1647                    }
1648                    _ => {
1649                        warn!(
1650                            sender = %sender,
1651                            error = %e,
1652                            error_code = classification.code(),
1653                            "Failed to decrypt media chunk, dropping"
1654                        );
1655                        let kind = DecryptionFailureKind::from_mls_error(&e);
1656                        self.emit_mls_decryption_failed(
1657                            sender,
1658                            Some(&group_id),
1659                            kind,
1660                            MlsOperationContext::Receive,
1661                        );
1662                        // Permanently undecryptable (or crypto recovery is
1663                        // switched off): the chunk is ACKed and stays
1664                        // dedup-marked, so this loss is terminal for the
1665                        // transfer — surface it to the app, not just as MLS
1666                        // telemetry.
1667                        if let Ok(state) = lock_shared_state(&self.shared_state) {
1668                            state.emit_event(Event::message_decryption_failed(
1669                                message.id.clone(),
1670                                sender.to_string(),
1671                                Self::decryption_failure_code_from_kind(kind),
1672                                format!(
1673                                    "encrypted media chunk failed to decrypt ({}); its file transfer cannot complete",
1674                                    classification.code()
1675                                ),
1676                            ));
1677                        }
1678                        MediaChunkDecrypt::Dropped
1679                    }
1680                }
1681            }
1682        }
1683    }
1684}
1685
1686/// Four-way outcome of [`OfflineProtocol::decrypt_media_chunk`]: the plaintext,
1687/// a deferral (not delivered, but recoverable by the sender's resend — either
1688/// queued because the session is not ready, or dropped as undecryptable with
1689/// the ACK withheld), a security rejection (an illegitimate frame the caller
1690/// must answer with silence), or a terminal drop the caller ACKs.
1691enum MediaChunkDecrypt {
1692    Plaintext(Vec<u8>),
1693    Deferred,
1694    /// The frame is not a legitimate message from the claimed sender: its
1695    /// envelope named another pair's session slot, or the MLS credential
1696    /// authenticated a different member than the wire envelope claims. Mirrors
1697    /// the text path's [`InternalMessageResult::SecurityRejected`] — the caller
1698    /// must NOT ACK (an ACK confirms to an injector that the target is online
1699    /// and processing their frames, which is exactly what the text path refuses
1700    /// to reveal) and must unmark the id.
1701    ///
1702    /// [`InternalMessageResult::SecurityRejected`]: super::InternalMessageResult::SecurityRejected
1703    SecurityRejected,
1704    Dropped,
1705}
1706
1707/// Whether an MLS decrypt error is a *security* rejection rather than a session
1708/// state problem — the two classes for which the receiver must stay silent
1709/// instead of ACKing.
1710///
1711/// Used as the match guard that intercepts these variants *before*
1712/// [`SessionStateError`] classification, exactly as the text path does inline in
1713/// `handle_encrypted_message`. The intercept cannot be replaced by a
1714/// classification arm: both variants map to [`SessionStateError::Unknown`], and
1715/// `Unknown` must keep its terminal drop-and-ACK disposition for the classes
1716/// that genuinely belong there (notably `CommitNotAuthorized`, a permanent
1717/// refusal whose sender gains nothing from retrying).
1718///
1719/// Kept as a named predicate rather than an inline pattern so the classification
1720/// itself is testable: `SenderIdentityMismatch` is not reachable through any
1721/// SDK-built ciphertext (see
1722/// `test_media_security_rejection_classifies_both_identity_mismatches`).
1723pub(super) fn is_media_security_rejection(error: &offline_protocol_mls::MlsError) -> bool {
1724    matches!(
1725        error,
1726        offline_protocol_mls::MlsError::SenderIdentityMismatch { .. }
1727            | offline_protocol_mls::MlsError::SessionIdentityMismatch { .. }
1728            // The leaf-binding refusals join them for the same reason: an
1729            // `__MLS_ENC__` envelope naming a `group:` id reaches group decrypt
1730            // through this path, both classify as `Unknown`, and `Unknown` must
1731            // keep drop-and-ACK for `CommitNotAuthorized`.
1732            | offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1733            | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1734    )
1735}