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}