offline_protocol/protocol/mod.rs
1//! Main protocol engine.
2
3mod blocking;
4mod config_accessors;
5#[cfg(feature = "data")]
6pub(crate) mod data;
7#[cfg(feature = "data")]
8mod data_sync;
9mod decryption_queue;
10pub(crate) mod mesh_relay;
11mod message_dispatch;
12mod nostr_discovery;
13mod nostr_publication;
14mod observability;
15mod pending_queue;
16mod prefixes;
17pub(crate) mod reachability;
18mod receive;
19mod security;
20mod send;
21mod session;
22pub(crate) mod state_crypto;
23mod storage;
24mod types;
25
26pub(crate) use decryption_queue::PendingDecryptionQueue;
27pub use decryption_queue::PendingQueueMetrics;
28pub(crate) use prefixes::*;
29pub(crate) use storage::{PruneAllowance, RestorableRecord};
30pub(crate) use types::*;
31pub use types::{GatewayCarrier, MediaSendOptions, ProtocolState, SendMessageOptions};
32
33use crate::file_transfer::{FileTransferManager, OutboundTransferState};
34use crate::group_mesh::{MAX_RELAY_CAPABILITIES, MAX_RELAY_CAPABILITY_TOKEN_BYTES};
35use crate::mls_observability::{MlsEventEmitter, MlsEventRateLimiter, NoopMlsEventEmitter};
36use crate::telemetry::aggregator::{
37 build_metrics_frame, device_battery_from_available, diff_device_capability,
38 diff_transport_state, DeviceSnap,
39};
40#[cfg(feature = "telemetry-pipe")]
41use crate::telemetry::pipe::{
42 store::Backend, AppState, PipeParts, TelemetryHost, TelemetryPipe, TelemetryStats,
43 FINAL_FLUSH_BUDGET,
44};
45use crate::telemetry::{
46 dispatch_record, Scrubber, TelemetryConfig, TelemetryContext, TelemetryRecord, TelemetrySink,
47};
48use crate::{
49 Error, EstablishmentState, Event, ProtocolConfig, ProtocolStateStorage, Result,
50 TransportManager,
51};
52use chrono::{DateTime, Utc};
53use mesh_relay::{MeshRelayGovernor, RelayActivity};
54use offline_protocol_core::{LamportClock, Message, MessageId, MutexExt, Username};
55use offline_protocol_mls::{EncryptedMessage, MlsManager, MlsStorage, WelcomeMessage};
56use offline_protocol_reliability::{AckManager, Deduplicator, RetryQueue};
57use offline_protocol_router::{RelayPriority, RelayRole, TransportSelector};
58use offline_protocol_services::MeshServices;
59use offline_protocol_transport::{BleTransport, TransportStatus, TransportType};
60use std::collections::HashMap;
61use std::collections::HashSet;
62use std::sync::{Arc, Mutex, RwLock};
63use std::time::Instant;
64use tracing::{debug, error, info, warn};
65use zeroize::Zeroizing;
66
67/// Returns whether `timestamp` is at least `lifetime_ms` old without relying
68/// on Chrono's panicking `DateTime - Duration` operator.
69///
70/// A lifetime too large to represent, or whose cutoff predates Chrono's
71/// calendar range, cannot expire a representable timestamp.
72fn lifetime_expired(now: DateTime<Utc>, timestamp: DateTime<Utc>, lifetime_ms: u64) -> bool {
73 let Ok(lifetime_ms) = i64::try_from(lifetime_ms) else {
74 return false;
75 };
76 now.checked_sub_signed(chrono::Duration::milliseconds(lifetime_ms))
77 .is_some_and(|cutoff| timestamp <= cutoff)
78}
79
80/// Main entry point for the Offline Protocol SDK.
81///
82/// This struct combines all protocol components and provides a unified API
83/// for sending/receiving messages with automatic transport selection and
84/// reliable delivery.
85pub struct OfflineProtocol {
86 /// Configuration.
87 pub(crate) config: ProtocolConfig,
88
89 /// This device's identity on the wire: the address derived from the
90 /// identity key in this profile's storage.
91 ///
92 /// Every outbound frame is stamped with it, every self-filter compares
93 /// against it, and it is one half of every 1:1 session slot. It is set
94 /// once, by `initialize_mls`, from the key that storage holds — the
95 /// protocol cannot know it before then, because storage is what carries
96 /// the key and storage does not arrive until that call.
97 ///
98 /// Until then it holds [`ProtocolConfig::profile`], which is *not* an
99 /// address and is never a peer's real identity. That fallback keeps the
100 /// pre-MLS surface (transport wiring, local bookkeeping) working on an
101 /// instance that has not been initialized.
102 ///
103 /// One configuration can put the fallback on the wire: the explicit
104 /// `require_encryption = false` opt-out lets a send go out in plaintext
105 /// with MLS never initialized, and such a frame is stamped with the
106 /// profile. It is the same fall-through that already emits
107 /// [`crate::events::SecurityWarningCode::PlaintextSend`], so the condition
108 /// is reported rather than silent. The default config forbids it —
109 /// `require_encryption` is `true`, which fails the send closed.
110 pub(crate) local_id: String,
111
112 /// Whether [`Self::local_id`] is the derived address rather than the
113 /// profile fallback.
114 pub(crate) identity_established: bool,
115
116 /// Transport manager (manages all transports with DORS).
117 pub(crate) transport_manager: TransportManager,
118
119 /// ACK manager for tracking acknowledgments.
120 ack_manager: AckManager,
121
122 /// Retry queue for failed messages.
123 retry_queue: RetryQueue,
124
125 /// Deduplicator for preventing duplicates.
126 pub(crate) deduplicator: Deduplicator,
127
128 /// Decides which frames this device carries for other people, when they
129 /// go out, and how fast. Kept separate from [`Self::deduplicator`], whose
130 /// entries track messages addressed to us and are removed again when
131 /// delivery has to be retried.
132 pub(crate) mesh_relay: MeshRelayGovernor,
133
134 /// Shared mutable state.
135 shared_state: Arc<Mutex<SharedState>>,
136
137 /// Messages awaiting delivery/acknowledgment (store-and-forward outbox).
138 outbox: HashMap<MessageId, OutboxEntry>,
139
140 /// Transient staging for outbox re-seal provenance, keyed by message id.
141 /// Populated at send time (where the plaintext is in scope) and consumed
142 /// when the outbox entry is first created (which happens deep in the send/
143 /// retry machinery, from the already-sealed `Message`). Once consumed it
144 /// lives on the `OutboxEntry` itself. See [`OutboxReseal`].
145 pending_reseal: HashMap<MessageId, OutboxReseal>,
146
147 /// Dedicated outbox for file chunk messages, separate from the main outbox
148 /// to prevent large file transfers from evicting regular messages.
149 media_outbox: HashMap<MessageId, OutboxEntry>,
150
151 /// Consecutive unreachable-park counter per recipient for plain DMs,
152 /// driving the escalating reachability-probe interval on every carrier
153 /// (see `handle_recipient_unreachable_for_message` —
154 /// mirrors `WelcomeLifecycleRecord::unreachable_parks`). Reset on every
155 /// reachability edge (`flush_outbox_for_peer_via` / `flush_outbox_all`) and
156 /// on delivery; pruned alongside `cleanup_outbox` so only peers that
157 /// still hold outbox entries can retain a counter. While live, ACK
158 /// exhaustion for the recipient's plain DMs re-parks instead of
159 /// settling terminally (`try_repark_exhausted_dm`). In-memory only: a
160 /// restart re-drives the outbox anyway, which is itself a fresh probe.
161 dm_unreachable_parks: HashMap<String, u32>,
162
163 /// Per-recipient reachability claims, keyed by recipient and carrier.
164 ///
165 /// The generalisation of the counter above: that one records *how often*
166 /// a recipient has been parked, this one records *what a carrier said*
167 /// about reaching them, so a decision can be made before the send rather
168 /// than only after it failed. Written by the same producers (a gateway
169 /// verdict, a gateway presence answer), read at the send and carrying
170 /// seams. In-memory only, and absent facts mean today's behaviour.
171 pub(crate) reachability: reachability::ReachabilityFacts,
172
173 /// Capability tokens the attached Reticulum gateway advertised.
174 ///
175 /// Delivered at attach, before the bridge reports the carrier available,
176 /// and cleared when the carrier drops: a stale advertisement outlives the
177 /// gateway that made it, and a reconnect may land on a different one.
178 ///
179 /// Kept apart from `group_mesh.relay_capabilities` deliberately, though
180 /// both are capability sets from a gateway. That one gates the relay
181 /// group-broadcast path, and a token advertised by whatever box is
182 /// plugged into the venue Wi-Fi must not be able to open it.
183 gateway_capabilities: HashSet<String>,
184
185 /// MLS manager for end-to-end encryption.
186 mls_manager: Option<Arc<RwLock<MlsManager>>>,
187
188 /// Pending messages waiting for session establishment (recipient -> messages).
189 pending_encrypted_messages: HashMap<String, Vec<PendingMessage>>,
190
191 /// Terminal message settlements produced while restoring, held until the
192 /// event pipeline is live.
193 ///
194 /// Restore runs inside `initialize_mls`, which apps routinely call before
195 /// installing an event callback — so a `message_failed` emitted there would
196 /// be dropped, and the app would keep an id that never resolves. `start()`
197 /// drains this, mirroring how restored media descriptors already wait to be
198 /// announced.
199 ///
200 /// Explicitly capped at [`MAX_DEFERRED_RESTORE_SETTLEMENTS`] rather than
201 /// left to the restore caps that feed it. Those caps do bound it, but they
202 /// bound it as a *sum* across every category, and nothing drains this until
203 /// `start()` — which an app that only ever calls `initialize_mls`, or that
204 /// retries it against a failing store, may never reach.
205 deferred_restore_settlements: Vec<Event>,
206
207 /// Number of settlements suppressed by the cap above, so the count is
208 /// reported even though the individual events are not.
209 suppressed_restore_settlements: usize,
210
211 /// Earliest wall-clock expiry in `pending_encrypted_messages`.
212 ///
213 /// `process()` consults this before scanning the bounded queue, avoiding an
214 /// O(N) walk on every 100 ms tick while preserving exact configured expiry.
215 /// A stale early deadline is harmless: it causes one extra scan, which then
216 /// recomputes the real minimum.
217 next_pending_message_expiry: Option<DateTime<Utc>>,
218
219 /// Key packages received but not yet used (sender_id -> package).
220 pub(crate) pending_key_packages: HashMap<String, ReceivedKeyPackage>,
221
222 /// Set of peers we've already sent our key package to.
223 pub(crate) key_package_sent_to: std::collections::HashSet<String>,
224
225 /// All discovered/connected peers, tracked independently of encryption.
226 /// Used by service discovery to know who to broadcast queries to.
227 /// Values are last-seen instants: refreshed on every discovery signal,
228 /// swept by [`Self::prune_stale_known_peers`] after `KNOWN_PEER_TTL_SECS`,
229 /// and used to pick the least-recently-seen victim when an insert hits
230 /// `MAX_KNOWN_PEERS`.
231 known_peers: HashMap<String, Instant>,
232
233 /// Sessions confirmed established (received Welcome or successful decrypt).
234 /// Only encrypt messages when the session is confirmed to avoid race conditions.
235 confirmed_sessions: std::collections::HashSet<String>,
236
237 /// Peers that have demonstrated they run MLS, used by
238 /// [`Self::accept_plaintext_content`] to reject cleartext claiming to come
239 /// from an encryption-capable peer.
240 ///
241 /// # Why this is not `confirmed_sessions`, and not a storage read
242 ///
243 /// The gate used to ask `is_session_confirmed`, which reads the
244 /// `session_states` protocol-state record. That record lives in the
245 /// install-scoped app container, where an attacker with write access can
246 /// simply **delete** it: restore then treats absence as `Pending` (it must
247 /// — see `bootstrap_missing_session_state`), the peer drops out of
248 /// `confirmed_sessions`, and the gate opens for unauthenticated cleartext
249 /// carrying an attacker-chosen `sender`. Sealing the record does not help;
250 /// a seal authenticates bytes that are present, and this attack removes
251 /// them. So the signal has to come from somewhere the app container cannot
252 /// reach.
253 ///
254 /// Both sources here are credential-store-resident: the MLS session list
255 /// and the durable capability records
256 /// ([`storage_keys::ENCRYPTION_CAPABLE_PEERS`]). Neither is sufficient
257 /// alone. A session can exist for a peer that has never signed a control
258 /// frame we verified (it is established from key packages, and the Welcome
259 /// path marks capability without a signature), and a record can outlive the
260 /// session it was learned alongside. The union is what actually tracks
261 /// "this peer speaks MLS".
262 ///
263 /// # Monotone within a run — deliberately, and this is load-bearing
264 ///
265 /// Entries are added and **never removed on session teardown**. That looks
266 /// like a leak and is in fact the whole security property. Teardown is
267 /// reachable from an *unauthenticated* frame: an injected `__MLS_ENC__`
268 /// classifies as `SessionDesync`, `schedule_session_rekey` tears down our
269 /// session and advertises a `session_reset` key package, and the peer's
270 /// `handle_key_package_message` then deletes its own. A set that shrank on
271 /// teardown would therefore let one injected frame re-open the very gate
272 /// this field exists to hold shut — moving the attack from "needs
273 /// app-container write access" to "needs one forged packet", which is
274 /// strictly worse than the bug it fixes.
275 ///
276 /// Keeping a peer after teardown costs nothing real: a peer that spoke MLS
277 /// a moment ago is still encryption-capable, so its cleartext is still a
278 /// downgrade. The invariant to preserve is one-directional — this set may
279 /// **over**-approximate (which fails closed) but must never
280 /// **under**-approximate (which fails open). Anything that populates it may
281 /// be lossy; nothing may prune it.
282 ///
283 /// There is no longer any way out at all, and that is now *correct* rather
284 /// than merely safe. The old escape hatch (`reset_tofu_for_peer`) meant
285 /// "this identity has re-keyed, treat it as new" — a thing that could
286 /// happen when an identity was a name. Under self-certifying addressing an
287 /// address is the hash of its key, so a peer that re-keys becomes a
288 /// *different address*: nothing about the old one ever stops being true,
289 /// and forgetting it could only ever be wrong.
290 ///
291 /// Bounded by [`MAX_ENCRYPTION_CAPABLE_PEERS`], enforced as a refusal rather
292 /// than an eviction so the monotonicity above survives the cap — see
293 /// [`Self::mark_encryption_capable`].
294 encryption_capable_peers: std::collections::HashSet<String>,
295
296 /// Peers whose key package advertised the compact MLS envelope
297 /// ([`MLS_ENVELOPE_COMPACT_V1`] in `env_versions`), so
298 /// `seal_encrypted_content` may emit it instead of the legacy JSON form.
299 /// Learned from key-package exchange, persisted per peer as
300 /// [`PeerCapabilities`], and repopulated by `restore_peer_capabilities`
301 /// on `initialize_mls` — unlike the transport manager's binary-wire
302 /// registry, which stays in-memory because direct connections re-exchange
303 /// key packages anyway. Bounded like `key_package_sent_to` (it is keyed
304 /// by the wire-claimed sender).
305 peer_compact_envelope: std::collections::HashSet<String>,
306
307 /// Peers whose key package advertised that they verify the
308 /// freshness-bound control payload ([`CTRL_SIGN_V2`] in `ctrl_versions`),
309 /// so control frames addressed to them are signed under
310 /// `offline-ctrl-v2`. Same lifecycle as `peer_compact_envelope` above:
311 /// learned from key-package exchange, persisted as [`PeerCapabilities`],
312 /// restored on `initialize_mls`, bounded like `key_package_sent_to`.
313 ///
314 /// This is the **send** half, and it is a hint: forgetting a peer costs
315 /// them the older payload, which they still verify. The **accept** half is
316 /// [`Self::control_freshness_peers`], which is a ratchet and is not
317 /// allowed to forget.
318 ///
319 /// [`CTRL_SIGN_V2`]: offline_protocol_sealed::CTRL_SIGN_V2
320 peer_ctrl_freshness: std::collections::HashSet<String>,
321
322 /// Peers that have presented a control-frame signature over the
323 /// freshness-bound payload, so their frames under the older one are
324 /// refused.
325 ///
326 /// A **ratchet**, and the difference from every capability set above is
327 /// the whole point: those record what a peer said and may be forgotten
328 /// harmlessly, this records what a peer *proved* and forgetting it
329 /// re-opens the replay it exists to close. It is durable (in the peer's
330 /// [`EncryptionCapableEntry`], not in [`PeerCapabilities`], which is
331 /// overwritten wholesale by every key package) and has no removal path
332 /// short of forgetting the peer entirely.
333 ///
334 /// A strict subset of [`Self::encryption_capable_peers`] by construction,
335 /// so `MAX_ENCRYPTION_CAPABLE_PEERS` bounds it too. See
336 /// `record_control_freshness_proved` for why the ordering that makes that
337 /// true is load-bearing.
338 pub(crate) control_freshness_peers: std::collections::HashSet<String>,
339
340 /// Per-peer high-water mark: the signed timestamp of the most recent
341 /// session reset acted on from that peer.
342 ///
343 /// A reset is honoured only when its signed timestamp is strictly newer
344 /// than this, which is what stops one captured reset frame from being
345 /// spent again and again inside the freshness window. Chosen over a set of
346 /// spent frame ids because it is one integer per peer instead of a list
347 /// that grows with traffic, and because it is strictly stronger: it
348 /// refuses *every* older reset, including ones this node has no memory of.
349 ///
350 /// Durable in the peer's [`EncryptionCapableEntry`], and bounded by
351 /// construction like [`Self::control_freshness_peers`]: entries are only
352 /// written for peers the capped encryption-capable set accepted.
353 pub(crate) control_reset_watermark: std::collections::HashMap<String, i64>,
354
355 /// Peers whose key package advertised the sealed rich payload
356 /// ([`RICH_PAYLOAD_V1`] in `rich_versions`), so the send path may seal
357 /// rich extras (reply context, rich media metadata, forward attribution)
358 /// inside the `__RICH_V1__` body. Same lifecycle as
359 /// `peer_compact_envelope` above: learned from key-package exchange,
360 /// persisted, restored on `initialize_mls`, bounded like
361 /// `key_package_sent_to`. Forgetting a peer only costs silently dropped
362 /// rich extras — never a cleartext fallback.
363 peer_rich_payload: std::collections::HashSet<String>,
364
365 /// Peers whose key package advertised replicated-document sync
366 /// ([`DATA_SYNC_V1`] in `data_versions`), so the send path may push
367 /// `__DATA_V1__` frames to them. Same lifecycle as the two capability
368 /// sets above: learned from key-package exchange, persisted, restored on
369 /// `initialize_mls`, bounded like `key_package_sent_to`.
370 ///
371 /// Forgetting a peer costs replication with them until the next live
372 /// exchange, which is worse than it sounds: documents stay editable on
373 /// both sides and diverge quietly, so the symptom is not an error but
374 /// two people looking at different text. It is why this set is restored
375 /// rather than relearned.
376 ///
377 /// [`DATA_SYNC_V1`]: crate::protocol::types::DATA_SYNC_V1
378 peer_data_sync: std::collections::HashSet<String>,
379
380 /// Peers whose sealed-rich-payload support we learned *indirectly*: a
381 /// group inviter attested it on the Add commit (to existing members) or
382 /// the Welcome (to the joining member), because the members of a group
383 /// never directly exchange key packages with everyone else. Kept
384 /// separate from `peer_rich_payload` (direct self-advertisement) so
385 /// direct knowledge can stay authoritative: any directly received key
386 /// package from a peer evicts its attested entry. Consulted only by
387 /// `group_rich_seal_active` — never DM sealing (which always has direct
388 /// knowledge: session establishment exchanges key packages) and never
389 /// envelope selection (a stale attestation there could corrupt
390 /// decoding; here the worst case is one member rendering a literal
391 /// `__RICH_V1__` body). Persisted inside `PeerCapabilities`, restored
392 /// on `initialize_mls`, bounded like `key_package_sent_to`.
393 peer_rich_attested: std::collections::HashSet<String>,
394
395 /// Peers whose key package advertised *group* document replication
396 /// ([`DATA_GROUP_V1`] in `data_versions`), so a group they share with
397 /// this device may carry `__DATA_V1__` frames.
398 ///
399 /// Separate from `peer_data_sync` because the two entries mean
400 /// different things and a peer can advertise the first without the
401 /// second: an install predating group spaces replicates 1:1 perfectly
402 /// well and would render a group sync frame as chat text. Same
403 /// lifecycle as every capability set: learned from key-package
404 /// exchange, persisted, restored on `initialize_mls`, bounded like
405 /// `key_package_sent_to`.
406 ///
407 /// [`DATA_GROUP_V1`]: crate::protocol::types::DATA_GROUP_V1
408 peer_data_group: std::collections::HashSet<String>,
409
410 /// Peers whose *group* replication support we learned indirectly, from
411 /// an inviter's attestation on the Add commit or Welcome.
412 ///
413 /// The group analogue of `peer_rich_attested`, and load-bearing for the
414 /// same structural reason: members of a group never exchange key
415 /// packages with each other, so without attestation a group would
416 /// replicate only among whichever members happened to have met
417 /// directly. Direct knowledge stays authoritative — a key package from
418 /// the peer evicts its attested entry. Consulted only by
419 /// `group_data_sync_active`, never by 1:1 sync.
420 peer_data_group_attested: std::collections::HashSet<String>,
421
422 /// Peers whose key package advertised attachment and snapshot carriage
423 /// over the media path ([`DATA_MEDIA_V1`] in `data_versions`), so this
424 /// device may ask them for a blob and may hand them a document too large
425 /// to travel inside a sync frame.
426 ///
427 /// A third set rather than a flag on the first, for the same reason
428 /// `peer_data_group` is separate: a peer can advertise 1:1 replication
429 /// without this, and toward such a peer a data-purposed media transfer
430 /// arrives as an ordinary received file. Same lifecycle as every
431 /// capability set: learned from key-package exchange, persisted inside
432 /// `PeerCapabilities`, restored on `initialize_mls`, bounded like
433 /// `key_package_sent_to`.
434 ///
435 /// [`DATA_MEDIA_V1`]: crate::protocol::types::DATA_MEDIA_V1
436 peer_data_media: std::collections::HashSet<String>,
437
438 /// Peers already flagged with a `PlaintextSend` security warning, so the
439 /// explicit-opt-out plaintext path warns once per peer instead of once
440 /// per message.
441 pub(crate) plaintext_send_warned: std::collections::HashSet<String>,
442
443 /// Peers already flagged with a `PlaintextReceiveRejected` security
444 /// warning, so a chatty legacy or malicious peer warns once per peer
445 /// instead of once per rejected message. Keys are wire-claimed
446 /// (attacker-controllable) sender ids, so the set is bounded: it resets
447 /// at `MAX_PLAINTEXT_RECEIVE_WARNED_PEERS` instead of growing without
448 /// limit.
449 pub(crate) plaintext_receive_warned: std::collections::HashSet<String>,
450
451 /// Peers whose durable `encryption_capable_peers` record is known to be on
452 /// disk — either restored from it at boot or written successfully this run.
453 ///
454 /// Purely a write-elision cache for
455 /// [`OfflineProtocol::record_encryption_capable`]: a verified control frame
456 /// arrives on chatty prefixes (`__TYPING__`, `__PRESENCE__`), and the record
457 /// it would rewrite carries no field any decision reads. Tracking *landed
458 /// writes* rather than set membership is what keeps a best-effort storage
459 /// failure retryable on the peer's next frame instead of stranded for the
460 /// process.
461 ///
462 /// A subset of [`Self::encryption_capable_peers`] by construction, so
463 /// `MAX_ENCRYPTION_CAPABLE_PEERS` bounds it too.
464 pub(crate) encryption_capable_persisted: HashSet<String>,
465
466 /// Which control-gate rejection codes have already been reported per peer,
467 /// as a bitmask (see `OfflineProtocol::control_gate_warning_bit`), so a
468 /// peer whose control traffic keeps being refused warns once per code
469 /// instead of once per frame.
470 ///
471 /// Keys are wire-claimed (attacker-controllable) sender ids, so the map is
472 /// bounded: it resets at `MAX_CONTROL_GATE_WARNED_PEERS` instead of growing
473 /// without limit.
474 pub(crate) control_gate_warned: HashMap<String, u8>,
475
476 /// Bounded pending decryption queue for encrypted messages received before
477 /// the MLS session is ready.
478 pub(crate) pending_queue: PendingDecryptionQueue,
479
480 /// Secure storage for MLS/key material and SDK-owned secrets.
481 secure_storage: Option<Arc<dyn MlsStorage>>,
482
483 /// Install-scoped storage for restartable message-plane and protocol state.
484 protocol_state_storage: Option<Arc<dyn ProtocolStateStorage>>,
485
486 /// Seals sensitive protocol-state records before they reach the
487 /// install-scoped store, with a per-install key held in `secure_storage`.
488 ///
489 /// `None` until that key is available (and if it never becomes available,
490 /// for the whole session). Sealed categories then fail *closed*: they are
491 /// not persisted at all rather than written in the clear, since losing
492 /// crash recovery is recoverable and losing at-rest confidentiality is not.
493 /// See `state_crypto` and `restore_or_init_state_record_key`.
494 ///
495 /// Always belongs to the currently attached `secure_storage` — it is
496 /// re-derived, never carried across a storage swap.
497 state_record_cipher: Option<state_crypto::StateRecordCipher>,
498
499 /// Open replicated documents and their per-space indexes.
500 #[cfg(feature = "data")]
501 pub(crate) data: data::DataLayer,
502
503 /// When each peer was last offered our document versions.
504 ///
505 /// Neighbour rediscovery is not a rare event: a peer walking in and out
506 /// of Bluetooth range fires it repeatedly, and an offer per discovery
507 /// would put a frame on the mesh every time. The offer is not urgent
508 /// (anything committed locally is pushed as it happens) and it is not
509 /// load-bearing (the next trigger repeats it), so it is exactly the kind
510 /// of traffic that should be rate limited rather than merely small.
511 #[cfg(feature = "data")]
512 pub(crate) last_data_sync_offer: HashMap<String, Instant>,
513
514 /// Attachment fetches asked for and not yet answered, keyed by space and
515 /// blob hash, valued by the last sign that an answer is coming: the
516 /// moment the question went out, or the arrival of a chunk of its
517 /// answer.
518 ///
519 /// The refresh is what keeps this record's two jobs consistent. It bounds
520 /// how long a question is remembered, and it is also what admits the
521 /// bytes when they finally land, so a record that expired while its own
522 /// answer was arriving would discard a blob that fully crossed.
523 ///
524 /// Load-bearing for more than bookkeeping: arriving blob bytes are
525 /// admitted only against an entry here, checked when the transfer's
526 /// chunk 0 identifies it and again before the bytes reach the
527 /// application. Without it a peer could push blobs this device never
528 /// asked for and the application would be handed files nobody wanted.
529 ///
530 /// The bound it gives is on delivery and on the bulk of the buffering,
531 /// not on every byte: chunks need not arrive in order, so any that land
532 /// before chunk 0 are buffered before there is anything to judge, and
533 /// are released when it arrives. What remains is bounded by the media
534 /// layer's own resource caps, the same ones every unsolicited file
535 /// transfer has always been subject to.
536 pub(crate) pending_attachment_fetches: HashMap<String, Instant>,
537
538 /// Blob requests recently acted on, keyed by asking peer and blob hash.
539 ///
540 /// Its own map rather than a corner of `last_data_sync_offer`, because
541 /// this is the only rate-limit key a peer chooses freely: every other one
542 /// is a peer or a peer-and-group, bounded by a roster. Bounded here by
543 /// [`MAX_BLOB_REQUESTS_PER_WINDOW`] per peer and
544 /// [`MAX_BLOB_REQUEST_WINDOWS`] overall, and swept on every read.
545 ///
546 /// [`MAX_BLOB_REQUESTS_PER_WINDOW`]: crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW
547 /// [`MAX_BLOB_REQUEST_WINDOWS`]: crate::protocol::data_sync::MAX_BLOB_REQUEST_WINDOWS
548 pub(crate) blob_request_windows: HashMap<String, Instant>,
549
550 /// Whether the MLS group list has been read into `group_mesh.members`
551 /// once this session, so the shared-group rediscovery sweep can see the
552 /// groups this device is in.
553 ///
554 /// That cache is filled on demand by group traffic and is empty after a
555 /// restart, so a sweep that only read it would find nothing on exactly
556 /// the launch where two members have drifted apart and neither is
557 /// editing. Enumerating is one key listing plus, per group not already
558 /// cached, the group-state read the next group send would have paid
559 /// anyway.
560 ///
561 /// One shot rather than per sweep because the cache stays complete once
562 /// filled: creating a group inserts, a Welcome inserts, and leaving
563 /// removes. Deliberately not a cold-start sweep, which was rejected for
564 /// putting an offer per member per group on the mesh at every launch;
565 /// this is per peer, only when that peer is actually in front of us, and
566 /// still behind the offer window.
567 #[cfg(feature = "data")]
568 pub(crate) group_spaces_enumerated: bool,
569
570 /// Lamport logical clock for causal message ordering.
571 /// Ticked on send, merged on receive.
572 lamport_clock: LamportClock,
573
574 /// Retry schedule for peers whose confirmation persistence failed.
575 confirmation_retry_due_at: HashMap<String, DateTime<Utc>>,
576
577 /// Probe schedule for pending sessions to guarantee post-restart convergence.
578 confirmation_probe_due_at: HashMap<String, DateTime<Utc>>,
579
580 /// Consecutive relay "recipient unreachable" verdicts per peer for the
581 /// session-confirmation probe. Drives an escalating backoff of
582 /// `confirmation_probe_due_at` (15s -> 600s cap, the same ladder the welcome
583 /// lifecycle uses) so a peer that established a session then vanished is not
584 /// probed every `CONFIRMATION_PROBE_INTERVAL_SECS` forever; a fleet of such
585 /// peers would otherwise exceed the relay's per-connection rate limit and
586 /// flap the connection. Reset on a reachability edge (`on_peer_presence`).
587 confirmation_probe_unreachable_parks: HashMap<String, u32>,
588
589 /// Token bucket that caps how fast the RETRY/PROBE path
590 /// (`process_retry_queue`) re-sends, so a large backlog of resends to
591 /// unreachable peers cannot burst past the relay's per-connection rate limit
592 /// and trigger the disconnect->reconnect->reflood loop ("flapping"). Only
593 /// consulted when `RetryConfig::edge_driven_unreachable_dm` is opted in. It
594 /// bounds ONLY resends — first-attempt sends, ACKs, control frames and
595 /// handshakes take other paths and are never throttled, so live traffic
596 /// keeps full throughput/latency. `retry_drain_last` is None until first use.
597 retry_drain_tokens: f64,
598 retry_drain_last: Option<Instant>,
599
600 /// Rate-limit schedule for 1:1 session re-keys triggered by an epoch-desync
601 /// decrypt failure. Bounds the re-key to at most one per peer per
602 /// `REKEY_INTERVAL_SECS` so a peer replaying stale-epoch ciphertext (or an
603 /// injected wrong-epoch frame) cannot drive a re-key storm. The floor is
604 /// **never reset early** — not even by a successful decrypt on the healed
605 /// session — because a genuine re-fork and a replay are indistinguishable
606 /// here; it lapses only by the interval elapsing (see
607 /// `schedule_session_rekey`). In-memory only: a re-key is a live-connectivity
608 /// action, and a fresh desync after restart simply re-arms it.
609 rekey_due_at: HashMap<String, DateTime<Utc>>,
610
611 /// Outbound welcome lifecycle records keyed by peer id.
612 welcome_lifecycles: HashMap<String, WelcomeLifecycleRecord>,
613
614 /// Outbound connection requests still awaiting a transport outcome,
615 /// keyed by message id → recipient. Lets `on_transport_send_failed`
616 /// turn the relay's authoritative "recipient offline" DeliveryError
617 /// into a typed `ConnectionRequestUndeliverable` event instead of
618 /// silently discarding it (the welcome-only path never matches a
619 /// connection request's message id). Entries are dropped on emission
620 /// and on proof of delivery (the request's delivery ack, or an inbound
621 /// accept/reject from the recipient), and TTL-pruned on insert and at
622 /// read; bounded, so an app spamming requests cannot grow it
623 /// unboundedly.
624 pending_connection_requests: HashMap<String, PendingConnectionRequest>,
625
626 /// Backoff state for presence-driven welcome rescue, keyed by peer id.
627 /// Bounds how often an online-but-never-confirming peer triggers a
628 /// welcome re-arm/re-send (see `on_peer_presence`). Entries are dropped
629 /// lazily once the peer has no unconfirmed welcome left.
630 welcome_presence_rescue: HashMap<String, PresenceRescueThrottle>,
631
632 /// Peers for which we are the both-create "owner" (kept our own session
633 /// group on the lexicographic tiebreak) and are still awaiting a
634 /// *group-aware* proof that the peer adopted our group. While a peer is in
635 /// this set, only `decrypt_success` may confirm the session — a plaintext
636 /// confirmation probe/ack is NOT group-aware (it only proves the peer holds
637 /// *some* session, possibly its own pre-adoption group) and must not be
638 /// allowed to stop our Welcome retransmission, or the peer could be left
639 /// stranded on a divergent group. Persisted (see
640 /// `restore_both_create_awaiting_decrypt`) so an owner restart mid-convergence
641 /// keeps the gate, rather than letting a stale plaintext probe/ack confirm
642 /// prematurely. Mutate only via `mark_both_create_awaiting_decrypt` /
643 /// `clear_both_create_awaiting_decrypt` to keep memory and storage in sync.
644 both_create_awaiting_decrypt: std::collections::HashSet<String>,
645
646 /// Sink for MLS lifecycle telemetry.
647 mls_event_emitter: Arc<dyn MlsEventEmitter>,
648
649 /// Rate limiting policy for MLS failure event floods.
650 mls_event_rate_limiter: MlsEventRateLimiter,
651
652 /// Pre-install scrubber used by MLS emit sites before a sink is
653 /// installed (`enable_telemetry`, or `install_sink` beneath it). Once one
654 /// is installed, emit sites read `self.telemetry.scrubber` instead via
655 /// `current_scrubber()`.
656 /// Both scrubbers share `telemetry_fallback_secret` so opaque identifiers
657 /// observed by the legacy `MlsEventEmitter` stay consistent across the
658 /// install boundary.
659 telemetry_scrubber: Scrubber,
660
661 /// Per-instance fallback secret for identifier scrubbing. Random at
662 /// construction. Reused by both the pre-install `telemetry_scrubber`
663 /// and by any scrubber built inside `install_sink` (unless the
664 /// installed `TelemetryConfig` carries its own `scrub_secret`). Keeping
665 /// the fallback stable across installs means the legacy
666 /// `MlsEventEmitter` path observes consistent opaque IDs before and
667 /// after a sink is installed on the same protocol instance.
668 telemetry_fallback_secret: [u8; 16],
669
670 /// Whether the persistent per-install scrub secret has been loaded from
671 /// (or initialized into) secure storage yet. Starts `false`; set `true`
672 /// the first time `restore_or_init_scrub_secret` succeeds so the load is
673 /// idempotent across the two storage-entry paths
674 /// (`initialize_mls` also enables persistence, which would otherwise
675 /// re-load and rebuild the scrubber a second time).
676 telemetry_secret_persisted: bool,
677
678 /// Whether the persistent per-install Nostr signing secret has been
679 /// loaded from (or initialized into) secure storage and installed into
680 /// the Nostr transport yet. Same idempotency role across the two
681 /// storage-entry paths as `telemetry_secret_persisted`; see
682 /// `restore_or_init_nostr_signing_secret`.
683 nostr_secret_persisted: bool,
684
685 /// A Nostr signing secret that was installed into the transport but
686 /// could not be persisted (storage `store` failed). Kept so the next
687 /// `restore_or_init_nostr_signing_secret` attempt retries persisting
688 /// this same secret instead of rotating the install's relay-visible
689 /// identity again mid-session.
690 nostr_unpersisted_secret: Option<Zeroizing<[u8; 32]>>,
691
692 /// Installed telemetry context (sink + config + scrubber). `None` until
693 /// `enable_telemetry` installs one; thereafter shared with
694 /// `SharedState` via `Arc` clone so both emit paths dispatch through the
695 /// same configuration.
696 ///
697 /// The duplication with [`SharedState::telemetry`] is deliberate: MLS
698 /// lifecycle emission is driven from `&self` on `OfflineProtocol` and
699 /// does not hold the shared-state lock, while protocol-event emission
700 /// happens inside `SharedState::emit_event` under the lock. Each path
701 /// reads the context from whichever side it already has in hand, and
702 /// `install_sink` is the single writer that keeps both copies in sync
703 /// (guaranteed atomic from the caller's perspective because `&mut self`
704 /// excludes concurrent calls), and `detach_telemetry_pipe` the single
705 /// clearer.
706 pub(crate) telemetry: Option<Arc<TelemetryContext>>,
707
708 /// The running telemetry pipe, when `enable_telemetry` built one. Its
709 /// sink is what `telemetry` dispatches into; the pipe itself is kept so
710 /// the tick can wake it and `initialize_mls` can hand it storage.
711 #[cfg(feature = "telemetry-pipe")]
712 telemetry_pipe: Option<Arc<TelemetryPipe>>,
713
714 /// File transfer manager for chunking outbound and reassembling inbound media.
715 file_transfer_manager: FileTransferManager,
716
717 /// Stashed (content_type, media_metadata) from chunk-0 of incoming file transfers,
718 /// used to populate the FileReceived event once all chunks arrive.
719 pending_media_metadata: HashMap<String, PendingMediaMetadataEntry>,
720 /// Tracks outbound media transfer delivery progress keyed by file_id.
721 outbound_media_transfers: HashMap<String, OutboundMediaTransfer>,
722 /// Maps each outbound media chunk message ID to (file_id, chunk_index).
723 outbound_media_chunks: HashMap<MessageId, (String, u32)>,
724 /// Sliding-window state for outbound file transfers, keyed by file_id.
725 /// Chunks are only sent when the window has capacity (previous chunks ACKed).
726 outbound_media_windows: HashMap<String, OutboundTransferState>,
727 /// Descriptors of transfers that were in flight when the previous process
728 /// died, restored from storage. `start()` emits one `MediaResendRequired`
729 /// event each but deliberately does NOT drain them: entries stay parked
730 /// (and persisted) until a same-`file_id` resend consumes them — after
731 /// `send_media_with` checksum-validates the re-supplied bytes against
732 /// the descriptor — or the restore TTL prunes them, so an app that
733 /// misses the signal gets it again next restart.
734 restored_media_descriptors: HashMap<String, MediaTransferDescriptor>,
735
736 /// Mesh service registry and handler (extracted crate).
737 mesh_services: MeshServices,
738
739 /// Bundled state for mesh group messaging (member cache, dedup, pending commits).
740 pub(crate) group_mesh: crate::group_mesh::GroupMeshState,
741
742 /// Set of blocked user IDs. Messages from blocked users are silently
743 /// dropped (no ACK, no event). Persisted via `ProtocolStateStorage`.
744 blocked_users: HashSet<String>,
745
746 /// Timestamp of the last `kick_pending_session_reconciliation` execution.
747 /// Used to throttle reconciliation to avoid expensive storage I/O
748 /// (list_sessions → Keychain/Keystore) on every process tick / receive poll.
749 last_reconciliation_at: Option<Instant>,
750
751 /// Instant of the last periodic `MetricsFrame` emission, used to rate
752 /// the telemetry aggregator to `TelemetryConfig::metrics_cadence`.
753 last_metrics_emit_at: Option<Instant>,
754
755 /// Last observed per-transport status map, used by the telemetry
756 /// aggregator to diff against the current map and emit one
757 /// `TransportStateEvent` per transition.
758 transport_status_snapshot: HashMap<TransportType, TransportStatus>,
759
760 /// Last observed (battery, charging, relay_role) snapshot, used by the
761 /// telemetry aggregator to fire `DeviceCapabilitySnapshot` on change.
762 device_capability_snapshot: Option<DeviceSnap>,
763
764 /// The Lamport clock value at the time of the last storage write.
765 /// Used to debounce `persist_lamport_clock()` — only write when the
766 /// in-memory value has advanced past this by `LAMPORT_PERSIST_INTERVAL`
767 /// ticks. On crash, at most `LAMPORT_PERSIST_INTERVAL` ticks are lost,
768 /// which is harmless for a logical clock (the gap is absorbed by the
769 /// next merge with any peer).
770 last_persisted_lamport: u64,
771
772 /// Receive-side changes to the deduplicator's seen set since it was last
773 /// written. Drives the batched write in `persist_dedup_seen_if_due`;
774 /// see `DedupSeenRecord` for why the set is persisted at all.
775 dedup_dirty: u32,
776
777 /// When the seen set was last written (or restored), for the time half of
778 /// the batching rule. Starts at construction so the first write waits for
779 /// either the interval or the dirty threshold like every later one.
780 dedup_last_persist: Instant,
781
782 /// The Nostr receive watermark last written to storage, or `None` if this
783 /// session has neither written nor restored one. Debounces
784 /// `persist_nostr_watermark()` the same way `last_persisted_lamport`
785 /// debounces the clock.
786 ///
787 /// Deliberately *not* captured for `initialize_mls` rollback, matching
788 /// where the restore itself runs: the mark lives in the transport, which
789 /// the rollback cannot reach, and re-installing a durable value that only
790 /// ever moves forward is not something a failed MLS restore needs undone.
791 last_persisted_nostr_watermark: Option<i64>,
792
793 /// Which MLS key package stands in each published Nostr slot.
794 ///
795 /// Persisted, because the alternative to remembering it is republishing
796 /// under fresh slot ids on every launch — leaving the old records standing
797 /// with packages we would no longer track for consumption.
798 nostr_publication_slots: Vec<nostr_publication::NostrPublicationSlot>,
799
800 /// Slot ids published during *this* process.
801 ///
802 /// Deliberately not persisted: an addressable record lives on the relays,
803 /// so a launch cannot know which of them still hold it. Publishing each
804 /// slot once per process is the cheap way to be sure — it replaces rather
805 /// than accumulates, so the cost of being wrong is one redundant event.
806 nostr_published_slots: HashSet<String>,
807
808 /// When the publication slots were last re-scanned, throttling the MLS
809 /// storage reads that scan costs. See `nostr_slot_refresh_due`.
810 last_nostr_slot_refresh: Option<Instant>,
811
812 /// Per-slot republish backoff after publications that never reached a
813 /// relay. Memory-only, and pruned to the live slot ids each refresh.
814 nostr_publication_backoff: HashMap<String, nostr_publication::PublicationBackoff>,
815
816 /// When the slot-exhaustion security warning was last emitted, suppressing
817 /// repeats for a cause that by nature persists.
818 last_nostr_slot_warning: Option<Instant>,
819
820 /// The username this install has published a discovery claim for, as last
821 /// persisted. `None` means no claim stands.
822 ///
823 /// Persisted, because it is the only thing that knows *which* name to
824 /// retract when the profile changes or the feature is turned off. See
825 /// `storage_keys::NOSTR_DISCOVERY_CLAIM`.
826 nostr_discovery_claim: Option<Username>,
827
828 /// Names whose tombstone has not yet reached a relay, oldest first.
829 ///
830 /// Persisted beside the claim, and for a stronger reason than the claim
831 /// is: the claim can be rebuilt from `config.profile`, while a retracted
832 /// name has no other record anywhere once the profile has moved on.
833 /// Dropping it before the tombstone lands strands a human-readable name in
834 /// a public directory pointing at an address whose key packages are still
835 /// being published, so nothing downstream expires it.
836 nostr_discovery_retracting: Vec<Username>,
837
838 /// Whether the standing claim has been published during *this* process.
839 ///
840 /// Not persisted, for the same reason `nostr_published_slots` is not: an
841 /// addressable record lives on the relays and a launch cannot know which
842 /// of them still hold it. Republishing once per process replaces rather
843 /// than accumulates.
844 nostr_discovery_published: bool,
845
846 /// Republish backoff for the discovery claim, keyed by its tag.
847 ///
848 /// Memory-only and structurally identical to
849 /// `nostr_publication_backoff`: a relay that rejects kind 30777 outright
850 /// would otherwise be retried once per refresh forever.
851 nostr_discovery_backoff: HashMap<String, nostr_publication::PublicationBackoff>,
852
853 /// When the discovery claim was last re-examined, throttling the tick.
854 last_nostr_discovery_refresh: Option<Instant>,
855
856 /// In-flight username resolutions, keyed by query id.
857 ///
858 /// A resolution accumulates every verified claim for its username and
859 /// emits **one** event carrying the whole set. See
860 /// `nostr_discovery::PendingResolution` for why the set is the only thing
861 /// that is ever emitted.
862 nostr_resolutions: HashMap<String, nostr_discovery::PendingResolution>,
863
864 /// Username lookups requested but not yet minted into a relay query, with
865 /// the time each was asked for.
866 ///
867 /// The transport holds the queue; this holds the *deadline*. They are
868 /// separate because minting is what may never happen: the platform pumps
869 /// queries only while the relay socket is up, so a lookup requested offline
870 /// would otherwise have no clock running behind it and no event to arrive.
871 /// Bounded by the transport's own resolve-queue ceiling, since an entry is
872 /// recorded only when the transport accepts the lookup.
873 nostr_resolution_requests: HashMap<Username, Instant>,
874
875 /// When the push key-package pool-exhaustion warning was last emitted.
876 ///
877 /// Suppressed for the same reason the slot warning is: the condition is a
878 /// pool at its ceiling, which persists until packages are consumed or
879 /// expire, and every push while it holds would otherwise emit.
880 last_push_key_package_warning: Option<Instant>,
881
882 /// When the last `GatewayAddressDeclarationRefused` and
883 /// `GatewayAddressBindingMismatch` warnings were emitted, for the same
884 /// suppression the push-key-package warning uses.
885 last_gateway_refusal_warning: Option<Instant>,
886 last_gateway_binding_mismatch_warning: Option<Instant>,
887}
888
889impl Drop for OfflineProtocol {
890 fn drop(&mut self) {
891 // Flush debounced Lamport clock so no ticks are lost when the
892 // protocol is dropped without an explicit stop() call.
893 self.flush_lamport_clock();
894 self.flush_nostr_watermark();
895 // And the deduplicator's seen set, whose write is batched the same
896 // way: a copy of a message received in the last few seconds before
897 // the drop would otherwise be re-processed by the next launch.
898 self.flush_dedup_seen();
899 // Same reason: edits batch before they reach a record, so without a
900 // flush here the debounce window between an edit and its delta is a
901 // window in which work is lost.
902 #[cfg(feature = "data")]
903 let _ = self.data_flush_all();
904 }
905}
906
907#[cfg(test)]
908struct TestProtocolStateStorage {
909 storage: Arc<dyn MlsStorage>,
910}
911
912/// Maps the fixture's MLS-storage failures onto the protocol-state contract,
913/// mirroring what the UniFFI adapter does for real providers.
914#[cfg(test)]
915pub(crate) fn map_test_storage_error(
916 error: offline_protocol_mls::StorageError,
917) -> crate::ProtocolStateError {
918 use crate::ProtocolStateError as P;
919 use offline_protocol_mls::StorageError as S;
920 match error {
921 S::KeyNotFound(detail) => P::NotFound(detail),
922 S::CorruptedData(detail) => P::Corrupted(detail),
923 S::StoreFailed(detail) => P::StoreFailed(detail),
924 S::LoadFailed(detail) => P::LoadFailed(detail),
925 S::DeleteFailed(detail) => P::DeleteFailed(detail),
926 S::Unavailable(detail) => P::LoadFailed(detail),
927 // Required by `#[non_exhaustive]` on StorageError. This fixture mirrors
928 // the UniFFI adapter, whose own fallback also degrades to `LoadFailed`.
929 ref other => P::LoadFailed(other.to_string()),
930 }
931}
932
933#[cfg(test)]
934impl ProtocolStateStorage for TestProtocolStateStorage {
935 fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> crate::ProtocolStateResult<()> {
936 self.storage
937 .store(key_type, key_id, data)
938 .map_err(map_test_storage_error)
939 }
940
941 fn load(&self, key_type: &str, key_id: &str) -> crate::ProtocolStateResult<Option<Vec<u8>>> {
942 self.storage
943 .load(key_type, key_id)
944 .map_err(map_test_storage_error)
945 }
946
947 fn delete(&self, key_type: &str, key_id: &str) -> crate::ProtocolStateResult<()> {
948 self.storage
949 .delete(key_type, key_id)
950 .map_err(map_test_storage_error)
951 }
952
953 fn list_keys(&self, key_type: &str) -> crate::ProtocolStateResult<Vec<String>> {
954 self.storage
955 .list_keys(key_type)
956 .map_err(map_test_storage_error)
957 }
958}
959
960impl OfflineProtocol {
961 /// Creates a new protocol instance.
962 ///
963 /// # Arguments
964 ///
965 /// * `config` - Protocol configuration
966 ///
967 /// # Returns
968 ///
969 /// Returns `Ok(OfflineProtocol)` if successful, `Err` if configuration is invalid.
970 pub fn new(config: ProtocolConfig) -> Result<Self> {
971 // Validate configuration
972 config.validate()?;
973
974 // Create transport selector for DORS
975 let transport_selector = TransportSelector::with_config(config.dors.clone());
976
977 // Create transport manager
978 let transport_manager = TransportManager::new(transport_selector);
979
980 // Per-instance fallback secret for identifier scrubbing. Stable for
981 // the life of this protocol instance so opaque IDs stay consistent
982 // across any later `install_telemetry_sink` call that does not
983 // supply its own `scrub_secret`.
984 let telemetry_fallback_secret = *uuid::Uuid::new_v4().as_bytes();
985
986 Ok(Self {
987 transport_manager,
988 ack_manager: AckManager::with_config(config.reliability.ack.clone()),
989 retry_queue: RetryQueue::with_config(config.reliability.retry.clone()),
990 deduplicator: Deduplicator::with_config(config.reliability.dedup.clone()),
991 mesh_relay: MeshRelayGovernor::with_config(
992 config.profile.clone(),
993 config.mesh_relay.clone(),
994 ),
995 local_id: config.profile.clone(),
996 identity_established: false,
997 shared_state: Arc::new(Mutex::new(SharedState::new())),
998 outbox: HashMap::new(),
999 pending_reseal: HashMap::new(),
1000 media_outbox: HashMap::new(),
1001 dm_unreachable_parks: HashMap::new(),
1002 reachability: reachability::ReachabilityFacts::default(),
1003 gateway_capabilities: HashSet::new(),
1004 mls_manager: None,
1005 pending_encrypted_messages: HashMap::new(),
1006 deferred_restore_settlements: Vec::new(),
1007 suppressed_restore_settlements: 0,
1008 next_pending_message_expiry: None,
1009 pending_key_packages: HashMap::new(),
1010 key_package_sent_to: std::collections::HashSet::new(),
1011 known_peers: HashMap::new(),
1012 confirmed_sessions: std::collections::HashSet::new(),
1013 encryption_capable_peers: std::collections::HashSet::new(),
1014 peer_compact_envelope: std::collections::HashSet::new(),
1015 peer_ctrl_freshness: std::collections::HashSet::new(),
1016 control_freshness_peers: std::collections::HashSet::new(),
1017 control_reset_watermark: std::collections::HashMap::new(),
1018 peer_rich_payload: std::collections::HashSet::new(),
1019 peer_data_sync: std::collections::HashSet::new(),
1020 peer_data_group: std::collections::HashSet::new(),
1021 peer_data_group_attested: std::collections::HashSet::new(),
1022 peer_data_media: std::collections::HashSet::new(),
1023 peer_rich_attested: std::collections::HashSet::new(),
1024 plaintext_send_warned: std::collections::HashSet::new(),
1025 plaintext_receive_warned: std::collections::HashSet::new(),
1026 encryption_capable_persisted: HashSet::new(),
1027 control_gate_warned: HashMap::new(),
1028 pending_queue: PendingDecryptionQueue::default(),
1029 secure_storage: None,
1030 protocol_state_storage: None,
1031 state_record_cipher: None,
1032 #[cfg(feature = "data")]
1033 data: data::DataLayer::default(),
1034 #[cfg(feature = "data")]
1035 last_data_sync_offer: HashMap::new(),
1036 pending_attachment_fetches: HashMap::new(),
1037 blob_request_windows: HashMap::new(),
1038 #[cfg(feature = "data")]
1039 group_spaces_enumerated: false,
1040 lamport_clock: LamportClock::new(),
1041 confirmation_retry_due_at: HashMap::new(),
1042 confirmation_probe_due_at: HashMap::new(),
1043 confirmation_probe_unreachable_parks: HashMap::new(),
1044 retry_drain_tokens: 0.0,
1045 retry_drain_last: None,
1046 rekey_due_at: HashMap::new(),
1047 welcome_lifecycles: HashMap::new(),
1048 pending_connection_requests: HashMap::new(),
1049 welcome_presence_rescue: HashMap::new(),
1050 both_create_awaiting_decrypt: std::collections::HashSet::new(),
1051 mls_event_emitter: Arc::new(NoopMlsEventEmitter),
1052 mls_event_rate_limiter: MlsEventRateLimiter::default(),
1053 // The pre-install scrubber uses `TelemetryConfig::default()` —
1054 // `scrub_ids=true`, no explicit `scrub_secret`. Identifier hashing
1055 // is therefore on from the moment the protocol is constructed,
1056 // matching today's always-scrub MLS observability semantics. When
1057 // `install_telemetry_sink` later supplies a config with
1058 // `scrub_ids(false)`, a new scrubber is derived from that config
1059 // but reuses `telemetry_fallback_secret` so opaque IDs remain
1060 // stable across the install boundary.
1061 telemetry_scrubber: Scrubber::from_config(
1062 &TelemetryConfig::default(),
1063 telemetry_fallback_secret,
1064 ),
1065 telemetry_fallback_secret,
1066 telemetry_secret_persisted: false,
1067 nostr_secret_persisted: false,
1068 nostr_unpersisted_secret: None,
1069 telemetry: None,
1070 #[cfg(feature = "telemetry-pipe")]
1071 telemetry_pipe: None,
1072 file_transfer_manager: FileTransferManager::new(),
1073 pending_media_metadata: HashMap::new(),
1074 outbound_media_transfers: HashMap::new(),
1075 outbound_media_chunks: HashMap::new(),
1076 outbound_media_windows: HashMap::new(),
1077 restored_media_descriptors: HashMap::new(),
1078 mesh_services: MeshServices::new(),
1079 group_mesh: crate::group_mesh::GroupMeshState::default(),
1080 blocked_users: HashSet::new(),
1081 last_reconciliation_at: None,
1082 last_metrics_emit_at: None,
1083 transport_status_snapshot: HashMap::new(),
1084 device_capability_snapshot: None,
1085 last_persisted_lamport: 0,
1086 dedup_dirty: 0,
1087 dedup_last_persist: Instant::now(),
1088 last_persisted_nostr_watermark: None,
1089 nostr_publication_slots: Vec::new(),
1090 nostr_published_slots: HashSet::new(),
1091 last_nostr_slot_refresh: None,
1092 nostr_publication_backoff: HashMap::new(),
1093 last_nostr_slot_warning: None,
1094 nostr_discovery_claim: None,
1095 nostr_discovery_retracting: Vec::new(),
1096 nostr_discovery_published: false,
1097 nostr_discovery_backoff: HashMap::new(),
1098 last_nostr_discovery_refresh: None,
1099 nostr_resolutions: HashMap::new(),
1100 nostr_resolution_requests: HashMap::new(),
1101 last_push_key_package_warning: None,
1102 last_gateway_refusal_warning: None,
1103 last_gateway_binding_mismatch_warning: None,
1104 config,
1105 })
1106 }
1107
1108 /// Initializes MLS encryption and protocol persistence.
1109 ///
1110 /// This must be called before encryption can be used. The storage
1111 /// `secure_storage` must be a platform-native credential store.
1112 /// `protocol_state_storage` must be scoped to the app container and is
1113 /// removed when that container is deleted.
1114 ///
1115 /// Ownership model:
1116 /// - `OfflineProtocol` is the single authoritative owner of `MlsManager`
1117 /// - initialization is idempotent per protocol instance
1118 /// - subsequent calls return without replacing the existing manager
1119 /// - manager publication is transactional: restore must succeed before
1120 /// `mls_manager` becomes visible to callers
1121 ///
1122 /// The two storage domains are intentionally different trait objects. This
1123 /// prevents message-plane state from inheriting the lifecycle of Keychain
1124 /// or another credential store.
1125 pub fn initialize_mls(
1126 &mut self,
1127 secure_storage: Arc<dyn MlsStorage>,
1128 protocol_state_storage: Arc<dyn ProtocolStateStorage>,
1129 ) -> Result<()> {
1130 self.initialize_mls_inner(secure_storage, protocol_state_storage, true)
1131 }
1132
1133 /// Returns this device's address, once it has one.
1134 ///
1135 /// `None` before `initialize_mls`: the address is the hash of an identity
1136 /// key that lives in this profile's storage, and storage does not arrive
1137 /// until then. It never changes afterwards, and it is stable across
1138 /// restarts of the same profile because the key is.
1139 pub fn local_address(&self) -> Option<&str> {
1140 self.identity_established.then_some(self.local_id.as_str())
1141 }
1142
1143 /// `adopt_legacy_state` exists only so test fixtures can point both handles
1144 /// at one backend. Adoption moves records *between* the two stores, which
1145 /// on a shared backend would mean deleting a record through the same store
1146 /// it was just read from.
1147 ///
1148 /// There is one identity path and every caller takes it — a fixture that
1149 /// wants a particular identity seeds the key into `secure_storage` first
1150 /// (see [`crate::test_identity`]) rather than naming an id here, because an
1151 /// id nothing holds a key for is exactly what `MlsManager::new` refuses.
1152 fn initialize_mls_inner(
1153 &mut self,
1154 secure_storage: Arc<dyn MlsStorage>,
1155 protocol_state_storage: Arc<dyn ProtocolStateStorage>,
1156 adopt_legacy_state: bool,
1157 ) -> Result<()> {
1158 if self.mls_manager.is_some() {
1159 return Ok(());
1160 }
1161
1162 // The bootstrap inversion: load (or mint) the identity key first, and
1163 // take this device's wire identity from it. Before this point the
1164 // protocol has no address — `local_id` is still the profile — which is
1165 // exactly why the key has to be resolved here, ahead of the manager
1166 // that embeds the id in its credential and in every session slot.
1167 let (_, address) = MlsManager::load_or_create_identity(&secure_storage)?;
1168 let local_id = address.to_string();
1169
1170 let mut manager = MlsManager::new(&local_id, secure_storage.clone())?;
1171 // Installed before the manager becomes shared: the enforcement flag is
1172 // read on every group decrypt, and this is the one point where the
1173 // manager is still uniquely owned.
1174 manager.set_enforce_admin_commits(self.config.group.enforce_admin_commits);
1175 let manager = Arc::new(RwLock::new(manager));
1176
1177 // Keep initialization transactional so a restore failure cannot leave
1178 // partially-initialized MLS state visible and then permanently block retries.
1179 let previous_secure_storage = self.secure_storage.clone();
1180 let previous_protocol_state_storage = self.protocol_state_storage.clone();
1181 // The record cipher belongs to the secure store it was loaded from, so
1182 // it is part of the same transaction as the storage handles: taken here
1183 // so a failed init cannot leave the new store's key installed next to
1184 // the rolled-back handles, and restored below alongside them.
1185 let previous_state_record_cipher = self.state_record_cipher.take();
1186 let previous_pending_messages = self.pending_encrypted_messages.clone();
1187 let previous_pending_message_expiry = self.next_pending_message_expiry;
1188 // The inbound half of the same transaction: the restore re-admits
1189 // parked frames from the store being attached, so a rollback has to put
1190 // back what the queue held before, not an empty queue.
1191 let previous_pending_queue = self.pending_queue.clone();
1192 // Populated by restore steps that run before other fallible ones, so
1193 // they belong in the transaction like everything else: a failed init
1194 // must not leave a key-package cache, parked media descriptors, or an
1195 // owner gate sourced from a store the rollback has just detached.
1196 let previous_pending_key_packages = self.pending_key_packages.clone();
1197 let previous_restored_media_descriptors = self.restored_media_descriptors.clone();
1198 let previous_both_create_awaiting_decrypt = self.both_create_awaiting_decrypt.clone();
1199 let previous_confirmed_sessions = self.confirmed_sessions.clone();
1200 let previous_encryption_capable_peers = self.encryption_capable_peers.clone();
1201 let previous_welcome_lifecycles = self.welcome_lifecycles.clone();
1202 let previous_lamport_clock = self.lamport_clock.value();
1203 // The debounce watermark travels with the clock: `restore_lamport_clock`
1204 // sets both, so rolling back only the clock leaves a watermark ahead of
1205 // it and `persist_lamport_clock`'s `wrapping_sub` reads that as an
1206 // enormous delta — writing on every tick instead of every interval.
1207 let previous_last_persisted_lamport = self.last_persisted_lamport;
1208 let previous_blocked_users = self.blocked_users.clone();
1209 let previous_outbox = self.outbox.clone();
1210 let previous_peer_compact_envelope = self.peer_compact_envelope.clone();
1211 let previous_peer_ctrl_freshness = self.peer_ctrl_freshness.clone();
1212 // Snapshotted with its siblings so a failed re-initialize restores it
1213 // too. It is a ratchet, so the direction that matters is that a
1214 // rollback must not *lose* it: a peer we hold to the newer payload has
1215 // to still be held to it if the re-initialize is abandoned.
1216 let previous_control_freshness_peers = self.control_freshness_peers.clone();
1217 let previous_control_reset_watermark = self.control_reset_watermark.clone();
1218 let previous_peer_rich_payload = self.peer_rich_payload.clone();
1219 let previous_peer_data_sync = self.peer_data_sync.clone();
1220 let previous_peer_rich_attested = self.peer_rich_attested.clone();
1221 let previous_peer_data_group = self.peer_data_group.clone();
1222 let previous_peer_data_group_attested = self.peer_data_group_attested.clone();
1223 let previous_peer_data_media = self.peer_data_media.clone();
1224
1225 let previous_local_id = std::mem::replace(&mut self.local_id, local_id.clone());
1226 let previous_identity_established = self.identity_established;
1227 self.identity_established = true;
1228 // The governor took the profile at construction because no address
1229 // existed yet; it keys relay fan-out and jitter off the local id, so it
1230 // has to move to the real one here.
1231 self.mesh_relay.set_local_id(local_id);
1232
1233 self.secure_storage = Some(secure_storage);
1234 self.protocol_state_storage = Some(protocol_state_storage);
1235
1236 // Must precede every restore below: sealed protocol-state records
1237 // (pending messages, outbox, media descriptors) are unreadable without
1238 // this key, so restoring first would silently start from empty and then
1239 // overwrite durable state with that empty view.
1240 self.restore_or_init_state_record_key();
1241
1242 // Then adopt anything the pre-split build left in secure storage, so
1243 // the restores below see one complete view. Must follow the record key
1244 // (adoption seals what it moves) and precede every restore (a restore
1245 // that ran first would find nothing and then overwrite the legacy state
1246 // with that empty view). Best-effort and resumable: a failure here
1247 // leaves the legacy records in place for the next launch.
1248 if adopt_legacy_state {
1249 self.adopt_legacy_protocol_state();
1250 }
1251
1252 // The settlement queue is deliberately NOT captured for rollback, and
1253 // that is the whole point rather than an omission.
1254 //
1255 // The rollback below restores *in-memory* state. It restores no storage
1256 // state, because restore has none to give back: by the time a later
1257 // step fails, an earlier one has already deleted an unaddressable
1258 // pending queue, dropped a record its store reported corrupt, and
1259 // rewritten a peer's snapshot without the entries it evicted for
1260 // capacity. Those records are gone. So the invariant adoption is
1261 // documented with — nothing can re-derive a settlement for a record
1262 // that no longer exists — is not special to adoption; it holds for
1263 // every settlement produced under this transaction, and rolling any of
1264 // them back means the application keeps an id that resolves to nothing,
1265 // on this launch and on every launch after it.
1266 //
1267 // The cost of not rolling back is that a retry which re-examines a
1268 // still-present record can settle the same id twice. `message_failed`
1269 // is terminal and idempotent for any reasonable consumer, and a
1270 // duplicate terminal event is a far smaller lie than silence.
1271 //
1272 // Load the persistent scrub secret outside the transactional restore
1273 // below: it is independent of MLS state, and a later MLS-restore
1274 // rollback must not undo it (the secret is idempotent and reused on
1275 // the next attempt).
1276 self.restore_or_init_scrub_secret();
1277
1278 // Same reasoning for the Nostr signing secret: independent of MLS
1279 // state, idempotent, and must survive an MLS-restore rollback.
1280 self.restore_or_init_nostr_signing_secret();
1281
1282 // And for the Nostr receive watermark, which sits outside the
1283 // transaction below for a third reason on top of those two: it does
1284 // not live in `self` at all. The mark is transport state, so the
1285 // rollback — which restores in-memory fields of `OfflineProtocol` —
1286 // could not put it back even if it wanted to. Installing it under a
1287 // later failure is harmless: it only ever moves forward, and the worst
1288 // a stale-but-installed mark does is narrow one replay window.
1289 self.restore_nostr_watermark();
1290 self.restore_nostr_publication_slots();
1291 self.restore_nostr_discovery_claim();
1292
1293 // The launch's whole durable-delete allowance, in one place because the
1294 // bound is on the launch. A device-barrier storm kills *this call*, not
1295 // any single walk in it, so no walk may hand itself a pool — see
1296 // `PruneAllowance::pool`. Four of them, and the ceiling is their sum:
1297 //
1298 // - `advisory_prunes` is shared by the `ADVISORY_PRUNE_WALKS` walks
1299 // whose prunes are caches or advisory signals. They draw in the order
1300 // written below, and that order is part of the contract: each leaves
1301 // the ones after it a `MIN_ADVISORY_PRUNE_DELETES` floor and may take
1302 // the rest, so an earlier walk gets first call on whatever the later
1303 // ones do not reserve. Reordering them, or adding a sixth without
1304 // bumping `ADVISORY_PRUNE_WALKS`, changes who gets pruned under load.
1305 // - `pending_prunes` and `outbox_prunes` are private to the two
1306 // settlement-paired walks, because being starved there defers a
1307 // diagnostic or a *delivery* rather than a cache eviction, and
1308 // neither may be held hostage to a key-package flood in a category it
1309 // has nothing to do with.
1310 // - `inbound_prunes` is private to the two receive-side walks for the
1311 // same reason: the pending-decryption walk holds ciphertext the app
1312 // is told about when it is lost. It draws first, and the seen-set
1313 // restore takes whatever is left for its one possible delete.
1314 //
1315 // See `storage::MAX_RESTORE_PRUNE_DELETES`.
1316 let mut advisory_prunes = PruneAllowance::pool();
1317 let mut pending_prunes = PruneAllowance::pool();
1318 let mut outbox_prunes = PruneAllowance::pool();
1319 let mut inbound_prunes = PruneAllowance::pool();
1320
1321 // Restore state from previous session
1322 let restore_result = (|| {
1323 self.restore_pending_messages(&mut pending_prunes)?;
1324 self.restore_pending_decrypt_entries(&mut inbound_prunes);
1325 self.restore_lamport_clock();
1326 self.restore_dedup_seen(&mut inbound_prunes);
1327 self.restore_encryption_capable_peers();
1328 self.restore_blocked_users()?;
1329 self.restore_session_states_from_manager(manager.clone(), &mut advisory_prunes)?;
1330 self.restore_peer_key_packages(&manager, &mut advisory_prunes)?;
1331 // Must precede start(): flush_restored_confirmed_pending_messages
1332 // re-makes the rich seal decision against these sets, and an
1333 // empty set there silently drops queued rich extras.
1334 self.restore_peer_capabilities(&manager, &mut advisory_prunes);
1335 self.restore_welcome_lifecycles(&mut advisory_prunes)?;
1336 self.restore_outbox(&mut outbox_prunes)?;
1337 self.restore_media_descriptors(&mut advisory_prunes)?;
1338 self.restore_both_create_awaiting_decrypt();
1339 Ok(())
1340 })();
1341
1342 if let Err(err) = restore_result {
1343 self.mesh_relay.set_local_id(previous_local_id.clone());
1344 self.local_id = previous_local_id;
1345 self.identity_established = previous_identity_established;
1346 self.secure_storage = previous_secure_storage;
1347 self.protocol_state_storage = previous_protocol_state_storage;
1348 self.state_record_cipher = previous_state_record_cipher;
1349 self.pending_encrypted_messages = previous_pending_messages;
1350 self.next_pending_message_expiry = previous_pending_message_expiry;
1351 // Back to what the queue held before this call. Emptying it lost
1352 // frames that were never persisted (no store was attached, or the
1353 // best-effort write failed). Keeping what the restore added would
1354 // leave entries sourced from the store the rollback just detached:
1355 // draining one deletes nothing on disk, so the next launch restores
1356 // it and drains it again into a spent ratchet generation.
1357 self.pending_queue = previous_pending_queue;
1358 // `deferred_restore_settlements` is deliberately left alone — see
1359 // the comment where the other baselines are captured.
1360 self.pending_key_packages = previous_pending_key_packages;
1361 self.restored_media_descriptors = previous_restored_media_descriptors;
1362 self.both_create_awaiting_decrypt = previous_both_create_awaiting_decrypt;
1363 self.confirmed_sessions = previous_confirmed_sessions;
1364 // Restored for consistency with the rest of the transaction, not
1365 // for safety: this set only ever grows, so keeping the wider view
1366 // would also have been sound. Rolling it back keeps "a failed
1367 // initialize_mls leaves no trace" true without exception.
1368 self.encryption_capable_peers = previous_encryption_capable_peers;
1369 self.welcome_lifecycles = previous_welcome_lifecycles;
1370 self.lamport_clock = LamportClock::from_value(previous_lamport_clock);
1371 self.last_persisted_lamport = previous_last_persisted_lamport;
1372 self.blocked_users = previous_blocked_users;
1373 self.outbox = previous_outbox;
1374 self.peer_compact_envelope = previous_peer_compact_envelope;
1375 self.peer_ctrl_freshness = previous_peer_ctrl_freshness;
1376 self.control_freshness_peers = previous_control_freshness_peers;
1377 self.control_reset_watermark = previous_control_reset_watermark;
1378 self.peer_rich_payload = previous_peer_rich_payload;
1379 self.peer_data_sync = previous_peer_data_sync;
1380 self.peer_rich_attested = previous_peer_rich_attested;
1381 self.peer_data_group = previous_peer_data_group;
1382 self.peer_data_group_attested = previous_peer_data_group_attested;
1383 self.peer_data_media = previous_peer_data_media;
1384 return Err(err);
1385 }
1386
1387 self.mls_manager = Some(manager);
1388 #[cfg(feature = "telemetry-pipe")]
1389 self.attach_telemetry_storage();
1390 // A re-init can swap the identity, and with it which groups exist, so
1391 // the one-shot group enumeration has to run again. Past the last
1392 // `return Err` above, so a failed init leaves the flag alone.
1393 #[cfg(feature = "data")]
1394 {
1395 self.group_spaces_enumerated = false;
1396 }
1397 self.emit_mls_initialized();
1398
1399 // Announced only here, past the last `return Err` — a restore failure
1400 // rolls the address back, and announcing an identity the instance then
1401 // abandoned would leave the app addressing a device that is not this
1402 // one.
1403 self.emit_event(Event::identity_ready(self.local_id.clone()));
1404
1405 info!(profile = %self.config.profile, "MLS encryption initialized with split secure and protocol-state storage");
1406 Ok(())
1407 }
1408
1409 /// Test-only adapter for fixtures that intentionally use one
1410 /// in-memory/fault-injection backend to observe both storage domains.
1411 #[cfg(test)]
1412 pub(crate) fn initialize_mls_for_test(&mut self, storage: Arc<dyn MlsStorage>) -> Result<()> {
1413 let protocol_state_storage = Arc::new(TestProtocolStateStorage {
1414 storage: storage.clone(),
1415 });
1416 // Give the storage the identity this fixture's profile label stands
1417 // for, then take the production path over it. The instance comes up as
1418 // a genuine derived address, and it is the *same* address every time a
1419 // fixture says `"alice"` — which is what lets tests keep readable
1420 // labels while still exercising derived identity.
1421 crate::test_identity::seed_identity(&storage, &self.config.profile);
1422 self.initialize_mls_inner(storage, protocol_state_storage, false)
1423 }
1424
1425 /// Test-only init that takes the **production** identity path: the address
1426 /// is derived from the identity key in `storage` rather than forced.
1427 #[cfg(test)]
1428 pub(crate) fn initialize_mls_for_test_derived(
1429 &mut self,
1430 storage: Arc<dyn MlsStorage>,
1431 ) -> Result<()> {
1432 let protocol_state_storage = Arc::new(TestProtocolStateStorage {
1433 storage: storage.clone(),
1434 });
1435 self.initialize_mls_inner(storage, protocol_state_storage, false)
1436 }
1437
1438 /// Test-only storage initialization for persistence-focused fixtures that
1439 /// do not need an MLS manager.
1440 #[cfg(test)]
1441 pub(crate) fn enable_message_persistence_for_test(
1442 &mut self,
1443 storage: Arc<dyn MlsStorage>,
1444 ) -> Result<()> {
1445 self.secure_storage = Some(storage.clone());
1446 self.protocol_state_storage = Some(Arc::new(TestProtocolStateStorage { storage }));
1447 self.restore_or_init_state_record_key();
1448 self.restore_or_init_scrub_secret();
1449 self.restore_or_init_nostr_signing_secret();
1450 self.restore_nostr_watermark();
1451 self.restore_nostr_publication_slots();
1452 self.restore_nostr_discovery_claim();
1453 // Same four pools as `initialize_mls_inner`, for the same reason.
1454 let mut advisory_prunes = PruneAllowance::pool();
1455 let mut pending_prunes = PruneAllowance::pool();
1456 let mut outbox_prunes = PruneAllowance::pool();
1457 let mut inbound_prunes = PruneAllowance::pool();
1458 self.restore_pending_messages(&mut pending_prunes)?;
1459 self.restore_pending_decrypt_entries(&mut inbound_prunes);
1460 self.restore_lamport_clock();
1461 self.restore_dedup_seen(&mut inbound_prunes);
1462 self.restore_encryption_capable_peers();
1463 self.restore_blocked_users()?;
1464 self.restore_outbox(&mut outbox_prunes)?;
1465 self.restore_media_descriptors(&mut advisory_prunes)?;
1466 Ok(())
1467 }
1468
1469 /// Checks if MLS encryption is initialized.
1470 pub fn is_mls_initialized(&self) -> bool {
1471 self.mls_manager.is_some()
1472 }
1473
1474 /// Records that `peer_id` has demonstrated it runs MLS.
1475 ///
1476 /// The only way into [`Self::encryption_capable_peers`]. Call it from
1477 /// anywhere a peer proves encryption capability — creating or joining a 1:1
1478 /// session with them, or pinning their signing key. Calling it redundantly
1479 /// is free and calling it too often is safe; the failure that matters is
1480 /// *not* calling it, which leaves the plaintext gate open for that peer.
1481 ///
1482 /// # Bounded by refusal, never by eviction
1483 ///
1484 /// The keys are wire-claimed ids and need their own cap, like every other
1485 /// map keyed that way — but not for the reason this comment used to give.
1486 /// It cited an unsigned `__MLS_WELCOME__` reaching the marking path; the
1487 /// control gate now rejects unsigned control frames before dispatch, and
1488 /// `__MLS_WELCOME__` is in neither exemption list, so `handle_welcome_message`
1489 /// only ever runs on a verified frame.
1490 ///
1491 /// The cap survives that because a signature was never the bound. A
1492 /// signature proves the signer holds the key its address derives from; it
1493 /// does not prove the address belongs to anyone real, and minting a fresh
1494 /// one costs a keygen. So an attacker willing to spend keygens can still
1495 /// present an unbounded supply of distinct, perfectly well-signed peers —
1496 /// which is precisely what a cap on a wire-keyed map is for.
1497 ///
1498 /// It has to be a refusal. Evicting would un-mark a peer that really is
1499 /// encryption-capable — the fail-open direction this field exists to
1500 /// prevent — and would hand an attacker a way to unprotect a *chosen*
1501 /// victim by flooding. Refusing only declines to add knowledge: that peer
1502 /// falls through to the `is_session_confirmed` check in
1503 /// [`Self::accept_plaintext_content`], which is exactly what shipped before
1504 /// this set existed. So a flood costs later peers the improvement and never
1505 /// costs anyone the protection they already had.
1506 ///
1507 /// Legitimate peers are also first in line by construction: restore seeds
1508 /// the set from the session list and the durable capability records during
1509 /// `initialize_mls`, before `start()` admits any traffic.
1510 ///
1511 /// # Return value
1512 ///
1513 /// `true` when `peer_id` is in the set on return — whether this call added
1514 /// it or a previous one did. This is what bounds the *durable* record:
1515 /// [`Self::record_encryption_capable`](crate::OfflineProtocol) persists only
1516 /// when this returns `true`, so the storage category can never hold more
1517 /// peers than the cap below. Without that coupling a Sybil flood — cheap,
1518 /// since each identity is one Ed25519 keygen that signs honestly as itself
1519 /// — would write unbounded records, and the restore walk (bounded by
1520 /// `MAX_RESTORE_KEYS_PER_CATEGORY`) could then miss a legitimate peer's
1521 /// record and re-open the plaintext gate for them on the next launch.
1522 pub(crate) fn mark_encryption_capable(&mut self, peer_id: &str) -> bool {
1523 if peer_id.is_empty() || peer_id == self.local_id {
1524 return false;
1525 }
1526 if self.encryption_capable_peers.contains(peer_id) {
1527 return true;
1528 }
1529 if self.encryption_capable_peers.len() >= MAX_ENCRYPTION_CAPABLE_PEERS {
1530 debug!(
1531 peer_id = %peer_id,
1532 cap = MAX_ENCRYPTION_CAPABLE_PEERS,
1533 "Encryption-capability set at capacity; peer falls back to the session-state check"
1534 );
1535 return false;
1536 }
1537 self.encryption_capable_peers.insert(peer_id.to_string());
1538 debug!(peer_id = %peer_id, "Peer marked encryption-capable");
1539 if self.encryption_capable_peers.len() == MAX_ENCRYPTION_CAPABLE_PEERS {
1540 // Emitted on the transition into full, so it cannot become a
1541 // per-frame flood: the set only grows and has no removal path, so
1542 // this line is reachable exactly once per process.
1543 warn!(
1544 cap = MAX_ENCRYPTION_CAPABLE_PEERS,
1545 "Encryption-capability set reached capacity; further peers fall back to the \
1546 session-state check. Expected only under a forged-sender flood."
1547 );
1548 }
1549 true
1550 }
1551
1552 /// Whether `peer_id` is known to run MLS. See
1553 /// [`Self::encryption_capable_peers`] for why this is not a storage read.
1554 pub(crate) fn is_encryption_capable(&self, peer_id: &str) -> bool {
1555 self.encryption_capable_peers.contains(peer_id)
1556 }
1557
1558 /// Drops `peer_id` from the in-memory capability set **only**, leaving its
1559 /// durable record and write-elision cache entry in place.
1560 ///
1561 /// Test-only, and deliberately not a general "forget" — the set has no
1562 /// removal path by design (see [`Self::encryption_capable_peers`]). It
1563 /// reproduces one specific state that production does reach: the rollback in
1564 /// `initialize_mls` restores the pre-restore snapshot of the set while
1565 /// [`Self::encryption_capable_persisted`] keeps what the restore walk read,
1566 /// so the two disagree until the peer's next verified frame re-marks them.
1567 #[cfg(test)]
1568 pub(crate) fn forget_encryption_capable_for_test(&mut self, peer_id: &str) {
1569 self.encryption_capable_peers.remove(peer_id);
1570 }
1571
1572 /// Returns whether auto-encryption should be applied.
1573 fn should_auto_encrypt(&self) -> bool {
1574 self.config.encryption.enabled && self.mls_manager.is_some()
1575 }
1576
1577 /// Configures the MLS lifecycle event emitter.
1578 pub fn set_mls_event_emitter(&mut self, emitter: Arc<dyn MlsEventEmitter>) {
1579 self.mls_event_emitter = emitter;
1580 }
1581
1582 /// Installs the unified telemetry sink for this protocol instance.
1583 ///
1584 /// Every subsequent protocol [`Event`] and MLS lifecycle event is
1585 /// additionally forwarded to `sink` as a
1586 /// [`crate::telemetry::TelemetryRecord`], gated by the verbosity tier
1587 /// and identifier-scrubbing preferences in `config`. Protocol events
1588 /// flow through as [`TelemetryRecord::Protocol`], MLS lifecycle events
1589 /// as [`TelemetryRecord::Mls`].
1590 ///
1591 /// This does not replace the legacy [`crate::EventCallback`] and
1592 /// [`MlsEventEmitter`] paths — they continue to fire independently for
1593 /// backward compatibility.
1594 ///
1595 /// Events emitted before this call (for example
1596 /// [`crate::mls_observability::MlsLifecycleEvent::Initialized`] fired
1597 /// during `initialize_mls`) are not replayed to a sink installed later.
1598 ///
1599 /// # Re-install semantics
1600 ///
1601 /// Calling this method a second time *replaces* the previously installed
1602 /// sink and config — the old sink stops receiving records as soon as
1603 /// this call returns. The per-instance fallback secret is preserved so
1604 /// opaque identifiers stay stable across the swap when the new config
1605 /// does not carry its own `scrub_secret`.
1606 ///
1607 /// The per-tick diff snapshots (transport status, device capability,
1608 /// metrics cadence) are **rearmed** on every install: the next
1609 /// `process()` tick observes the current state as its baseline and emits
1610 /// no synthetic transitions for transports that were already available
1611 /// at install time. Apps that need the current transport statuses at
1612 /// install time should pull them explicitly via
1613 /// [`TransportManager::get_all_transport_statuses`].
1614 ///
1615 /// The escalation-trigger dedupe window (`ESCALATION_TRIGGER_DEDUPE_SECS`
1616 /// inside [`TransportManager`]) is **not** rearmed. A sink installed
1617 /// within that window after an escalation event fired to a previous
1618 /// sink may miss the next occurrence of the same reason until the
1619 /// window elapses. This is intentional: the dedupe is a property of
1620 /// the routing engine, not of the observer.
1621 ///
1622 /// # Lifecycle
1623 ///
1624 /// Installing a sink wires the structured routing-decision callback
1625 /// on the underlying [`TransportManager`]. The callback lives for the
1626 /// life of the protocol instance or until another
1627 /// `install_telemetry_sink` call replaces it — `start()` and `stop()`
1628 /// do not toggle it. This means apps can install a sink before
1629 /// `start()` and see routing records from the very first `send()`,
1630 /// and a `stop() → start()` cycle preserves the wiring so no
1631 /// re-install is needed. Conversely, apps that never install a sink
1632 /// pay no per-`send()` routing-emission overhead.
1633 ///
1634 /// # Errors
1635 ///
1636 /// Returns `Err` if the shared-state mutex is poisoned (indicating an
1637 /// earlier panic in another thread while holding the lock). On error no
1638 /// sink is installed, and the legacy emission paths are unaffected.
1639 ///
1640 /// [`TelemetryRecord::Protocol`]: crate::telemetry::TelemetryRecord::Protocol
1641 /// [`TelemetryRecord::Mls`]: crate::telemetry::TelemetryRecord::Mls
1642 // Crate-private since 0.26: the pipe is the one production sink, and the
1643 // engine's own tests are the remaining callers.
1644 #[cfg_attr(not(test), allow(dead_code))]
1645 pub(crate) fn install_telemetry_sink(
1646 &mut self,
1647 sink: Arc<dyn TelemetrySink>,
1648 config: TelemetryConfig,
1649 ) -> Result<()> {
1650 self.install_sink(
1651 sink,
1652 config,
1653 Arc::new(std::sync::atomic::AtomicBool::new(true)),
1654 )
1655 }
1656
1657 /// Installs `sink` behind `gate`, the flag every emit site reads first.
1658 fn install_sink(
1659 &mut self,
1660 sink: Arc<dyn TelemetrySink>,
1661 config: TelemetryConfig,
1662 gate: Arc<std::sync::atomic::AtomicBool>,
1663 ) -> Result<()> {
1664 // Forward the routing-diagnostic preference to the TransportManager
1665 // before wiring any callbacks so the very first routing decision
1666 // post-install already reflects the requested tier.
1667 self.transport_manager
1668 .set_routing_diagnostic(config.routing_diagnostic());
1669
1670 let ctx = TelemetryContext::with_gate(sink, config, self.telemetry_fallback_secret, gate);
1671 let mut state = lock_shared_state(&self.shared_state).map_err(|err| {
1672 error!(
1673 error = %err,
1674 "install_telemetry_sink: shared-state mutex poisoned; sink NOT installed",
1675 );
1676 err
1677 })?;
1678 state.telemetry = Some(ctx.clone());
1679 drop(state);
1680 self.telemetry = Some(ctx);
1681
1682 // Wire the structured routing-decision callback here so the
1683 // `TransportManager` pays the per-`send()` emission cost only when
1684 // a sink is actually installed, and so the wiring persists across
1685 // `stop() → start()` cycles (the callback reads `s.telemetry` on
1686 // every invocation and that field stays set). A subsequent
1687 // `install_telemetry_sink` replaces the closure with one capturing
1688 // the fresh `shared_routing` clone; both closures read the same
1689 // `SharedState::telemetry` slot, so even a concurrent in-flight
1690 // emission from the pre-replace closure would still resolve to
1691 // the currently-installed sink.
1692 let shared_routing = self.shared_state.clone();
1693 self.transport_manager
1694 .set_routing_decision_callback(Some(Arc::new(move |decision| {
1695 let s = shared_routing.lock_or_recover();
1696 if let Some(ctx) = s.telemetry.as_ref().filter(|ctx| ctx.enabled()) {
1697 let record = TelemetryRecord::Routing(Box::new(decision));
1698 // Dispatch is panic-isolated so a sink that panics
1699 // here cannot unwind through the live `MutexGuard`
1700 // and poison `SharedState`.
1701 dispatch_record(&ctx.sink, &record);
1702 }
1703 })));
1704
1705 // Rearm the per-tick diff snapshots so the first tick after install
1706 // reports honest transitions only. Without this, a sink installed
1707 // after start() would observe a synthetic `Unavailable → Available`
1708 // transition for every already-running transport on the next tick.
1709 let (statuses, available) = self.transport_manager.snapshot_status_and_available();
1710 self.transport_status_snapshot = statuses;
1711 let (battery_level, is_charging) =
1712 device_battery_from_available(self.transport_manager.current_transport(), &available);
1713 let relay_role = self.relay_role();
1714 self.device_capability_snapshot = Some(DeviceSnap::from_parts(
1715 battery_level,
1716 is_charging,
1717 relay_role,
1718 ));
1719 // Leave `last_metrics_emit_at` at None so the first tick after
1720 // install fires a fresh metrics snapshot — that is the bootstrap
1721 // payload a new sink actually wants (full counter state in one
1722 // record).
1723 self.last_metrics_emit_at = None;
1724
1725 Ok(())
1726 }
1727
1728 /// Enables telemetry: the SDK collects, batches and uploads accepted
1729 /// events to the ingest itself.
1730 ///
1731 /// `config` must carry the `api_key` and `app_id` the developer portal
1732 /// issued; [`TelemetryConfig::validate`] names any field that is refused,
1733 /// and the refusal is [`Error::TelemetryConfigInvalid`]. `host` names the
1734 /// platform stamped on every batch and the application state right now.
1735 ///
1736 /// Nothing waits on I/O here: the pipe's storage load and first upload
1737 /// run on its own thread. Batches are durable across a restart once a
1738 /// protocol-state store is attached, which on the mobile bindings
1739 /// happens inside `initialize_mls`; before that they are held in memory.
1740 /// Calling this again replaces the running pipe after its final flush.
1741 ///
1742 /// What leaves the device, when, and how to switch it off are in
1743 /// `docs/telemetry.md`.
1744 #[cfg(feature = "telemetry-pipe")]
1745 pub fn enable_telemetry(&mut self, config: TelemetryConfig, host: TelemetryHost) -> Result<()> {
1746 if let Err(field) = config.validate() {
1747 return Err(Error::TelemetryConfigInvalid(field.to_string()));
1748 }
1749 if let Some(previous) = self.detach_telemetry_pipe() {
1750 previous.stop(FINAL_FLUSH_BUDGET);
1751 }
1752 // Always started in memory, even when a store is already attached.
1753 // Adopting a backend loads the durable queue: an index read, up to
1754 // `MAX_PENDING_BATCHES` sealed record reads with a decrypt and a parse
1755 // each, a `list_keys` and the orphan sweep. Doing that here would run
1756 // it on the caller, holding `&mut self`, which on the mobile bindings
1757 // is the common order: `initialize_mls` has already attached storage
1758 // by the time an app enables telemetry. `attach_telemetry_storage`
1759 // hands the backend over instead, and the pipe's own thread adopts it.
1760 let device_id = self.telemetry_install_id();
1761 let pipe = TelemetryPipe::start(
1762 &config,
1763 PipeParts::production(host, Backend::Memory, device_id),
1764 )?;
1765 self.install_sink(pipe.sink(), config, pipe.enabled_flag())?;
1766 self.telemetry_pipe = Some(pipe);
1767 self.attach_telemetry_storage();
1768 Ok(())
1769 }
1770
1771 /// Disables telemetry: one final flush within [`FINAL_FLUSH_BUDGET`], then
1772 /// the uploader thread stops and every emit site goes back to its
1773 /// telemetry-off cost. Callers holding an outer lock should prefer
1774 /// [`Self::detach_telemetry_pipe`] and stop the pipe outside it.
1775 #[cfg(feature = "telemetry-pipe")]
1776 pub fn disable_telemetry(&mut self) {
1777 if let Some(pipe) = self.detach_telemetry_pipe() {
1778 pipe.stop(FINAL_FLUSH_BUDGET);
1779 }
1780 }
1781
1782 /// Detaches the running pipe without stopping it: the sink is
1783 /// uninstalled, the routing callback cleared, and the pipe handed back
1784 /// for the caller to stop once it holds no engine lock. Its final flush
1785 /// may block for up to [`FINAL_FLUSH_BUDGET`], which is why the two steps
1786 /// are separable.
1787 #[cfg(feature = "telemetry-pipe")]
1788 pub fn detach_telemetry_pipe(&mut self) -> Option<Arc<TelemetryPipe>> {
1789 let pipe = self.telemetry_pipe.take()?;
1790 self.telemetry = None;
1791 self.shared_state.lock_or_recover().telemetry = None;
1792 self.transport_manager.set_routing_decision_callback(None);
1793 self.transport_manager.set_routing_diagnostic(false);
1794 Some(pipe)
1795 }
1796
1797 /// The running telemetry pipe, if any.
1798 #[cfg(feature = "telemetry-pipe")]
1799 pub fn telemetry_pipe(&self) -> Option<Arc<TelemetryPipe>> {
1800 self.telemetry_pipe.clone()
1801 }
1802
1803 /// Stops or resumes collection without tearing the pipe down. Off, every
1804 /// emit site costs one atomic load; what is already queued still drains.
1805 #[cfg(feature = "telemetry-pipe")]
1806 pub fn set_telemetry_enabled(&self, enabled: bool) {
1807 if let Some(pipe) = &self.telemetry_pipe {
1808 pipe.set_enabled(enabled);
1809 }
1810 }
1811
1812 /// Asks the uploader to flush now. Returns immediately.
1813 #[cfg(feature = "telemetry-pipe")]
1814 pub fn flush_telemetry(&self) {
1815 if let Some(pipe) = &self.telemetry_pipe {
1816 pipe.flush();
1817 }
1818 }
1819
1820 /// Flushes and waits up to `budget` for the uploader to finish.
1821 #[cfg(feature = "telemetry-pipe")]
1822 pub fn flush_telemetry_blocking(&self, budget: std::time::Duration) -> bool {
1823 self.telemetry_pipe
1824 .as_ref()
1825 .is_some_and(|pipe| pipe.flush_blocking(budget))
1826 }
1827
1828 /// The pipe's counters, or `None` while telemetry is not enabled.
1829 #[cfg(feature = "telemetry-pipe")]
1830 pub fn telemetry_stats(&self) -> Option<TelemetryStats> {
1831 self.telemetry_pipe.as_ref().map(|pipe| pipe.stats())
1832 }
1833
1834 /// Closes the current telemetry session: summary, a blocking flush,
1835 /// then a fresh session id.
1836 #[cfg(feature = "telemetry-pipe")]
1837 pub fn end_telemetry_session(&self) {
1838 if let Some(pipe) = &self.telemetry_pipe {
1839 pipe.end_session();
1840 }
1841 }
1842
1843 /// Reports an application lifecycle transition to the pipe.
1844 #[cfg(feature = "telemetry-pipe")]
1845 pub fn notify_app_state(&self, state: AppState) {
1846 if let Some(pipe) = &self.telemetry_pipe {
1847 pipe.notify_app_state(state);
1848 }
1849 }
1850
1851 /// Hands the pipe the protocol-state store and the per-install id once
1852 /// `initialize_mls` has them. Adoption happens on the pipe's own thread.
1853 #[cfg(feature = "telemetry-pipe")]
1854 fn attach_telemetry_storage(&self) {
1855 let Some(pipe) = &self.telemetry_pipe else {
1856 return;
1857 };
1858 if let (Some(storage), Some(cipher)) =
1859 (&self.protocol_state_storage, &self.state_record_cipher)
1860 {
1861 pipe.attach_storage(storage.clone(), cipher.clone());
1862 }
1863 pipe.set_device_id(self.telemetry_install_id());
1864 }
1865
1866 /// Starts the protocol.
1867 ///
1868 /// # Returns
1869 ///
1870 /// Returns `Ok(())` if started successfully, `Err` if already started.
1871 pub fn start(&mut self) -> Result<()> {
1872 let state = lock_shared_state(&self.shared_state)?;
1873
1874 if state.state != ProtocolState::Stopped {
1875 return Err(Error::AlreadyStarted);
1876 }
1877
1878 // Start all transports
1879 drop(state);
1880 self.transport_manager.start()?;
1881
1882 // Apply the Nostr sealing kill switch here rather than at transport
1883 // construction: the transport is built by the bindings layer, so this
1884 // is the one place every caller — FFI, RN, and a pure-Rust embedder
1885 // that installed its own `NostrTransport` — passes through with the
1886 // config in hand. A no-op when Nostr is not installed.
1887 self.transport_manager
1888 .set_nostr_sealing_enabled(self.config.transport.nostr_sealing_enabled);
1889 self.transport_manager
1890 .set_nostr_cold_contact_enabled(self.config.transport.nostr_cold_contact_enabled);
1891 self.transport_manager.set_nostr_username_discovery_enabled(
1892 self.config.transport.nostr_username_discovery_enabled,
1893 );
1894
1895 // Wire DORS event callback so app receives dors_score_updated, dors_transport_selected,
1896 let shared = self.shared_state.clone();
1897 self.transport_manager
1898 .set_dors_event_callback(Some(Arc::new(move |event| {
1899 shared.lock_or_recover().emit_event(event);
1900 })));
1901
1902 // NOTE: the structured routing-decision callback is wired by
1903 // `install_telemetry_sink`, not here. Its lifetime is tied to sink
1904 // presence rather than protocol start/stop — see the docstring on
1905 // `install_telemetry_sink` for the rationale.
1906
1907 // Wire BLE fragment eviction callback so app receives FragmentAssemblyEvicted.
1908 if let Some(ble_arc) = self.transport_manager.get_transport(TransportType::BLE) {
1909 let shared = self.shared_state.clone();
1910 if let Some(ble) = ble_arc.as_any().downcast_ref::<BleTransport>() {
1911 ble.set_fragment_eviction_callback(Some(Arc::new(move |info| {
1912 shared
1913 .lock_or_recover()
1914 .emit_event(Event::fragment_assembly_evicted(
1915 info.message_id,
1916 info.completion_percent,
1917 "capacity".to_string(),
1918 ));
1919 })));
1920 }
1921 }
1922
1923 let mut state = lock_shared_state(&self.shared_state)?;
1924
1925 state.state = ProtocolState::Running;
1926 drop(state);
1927
1928 // Settle what restore could not recover, now that the event pipeline is
1929 // live. These ids were handed to the app by `send_message*` before the
1930 // process died; without this they would never resolve to anything.
1931 // Drained before the flush below so an app sees the failures first and
1932 // cannot mistake a restored send for the settlement of a lost one.
1933 self.drain_deferred_restore_settlements();
1934
1935 self.flush_restored_confirmed_pending_messages();
1936
1937 // Cold start: sessions restored from storage are already confirmed,
1938 // so no confirmation transition will fire for them and nothing else
1939 // would offer versions until the peer happened to be rediscovered.
1940 // Frames toward an unreachable peer queue in the outbox, which is the
1941 // ladder doing its job rather than a reason not to try.
1942 #[cfg(feature = "data")]
1943 {
1944 // The space list is derived from the records themselves rather
1945 // than from the space index, because the index is a cache: a
1946 // document written without ever being explicitly created leaves
1947 // records but no index entry, and those are precisely the
1948 // documents no peer has seen yet.
1949 // Group spaces appear in this list and are deliberately not
1950 // swept: `kick_data_sync` answers only for a space named after
1951 // a peer, so a group space falls through it. A cold-start sweep
1952 // for groups would mean an offer per member per group at every
1953 // launch, and what it would recover is narrow — a local commit
1954 // is already pushed to the roster when it happens, and the
1955 // per-member outbox carries it across a restart. Rediscovery
1956 // and joins reconcile the rest.
1957 for space in self.data_list_spaces().unwrap_or_default() {
1958 self.kick_data_sync(&space, "start");
1959 }
1960 }
1961
1962 self.kick_pending_session_reconciliation("start");
1963 self.process_welcome_retry_queue()?;
1964
1965 // Re-drive delivery of any outbox entries restored from a previous
1966 // session. They land here in the "stranded" state (not in the retry
1967 // queue, not awaiting an ACK), which flush_outbox_all already recovers:
1968 // it sends immediately where a transport is available and re-enqueues
1969 // the rest with backoff. Runs at start() rather than at restore time
1970 // because transports aren't up yet during initialize_mls.
1971 self.flush_outbox_all();
1972
1973 // Media transfers that died with the previous process cannot be
1974 // re-driven — only their descriptors survive (never chunk bytes).
1975 // Tell the app which transfers to re-initiate, now that the event
1976 // pipeline is live. The descriptors stay parked (and persisted)
1977 // until the resend consumes them or the restore TTL prunes them,
1978 // so an app that misses this signal gets it again next restart.
1979 //
1980 // Except the layer's own. An application cannot answer one of those:
1981 // the only surface that takes a caller-supplied `file_id` is
1982 // `send_media_with`, which forces the purpose to `None`, so a
1983 // well-behaved app re-supplying the bytes would deliver a document
1984 // snapshot to the peer's user as a downloaded file. They are dropped
1985 // instead, and nothing is stranded: the requester's fetch expires and
1986 // can be asked again, and a snapshot is re-offered by the next
1987 // anti-entropy exchange.
1988 let (recoverable, internal): (Vec<_>, Vec<_>) = self
1989 .restored_media_descriptors
1990 .values()
1991 .cloned()
1992 .partition(|descriptor| descriptor.data_purpose.is_none());
1993 for descriptor in &internal {
1994 debug!(
1995 file_id = %descriptor.file_id,
1996 "Dropping an interrupted document-layer transfer; the asking side will retry"
1997 );
1998 self.restored_media_descriptors.remove(&descriptor.file_id);
1999 self.remove_media_descriptor(&descriptor.file_id);
2000 }
2001 for descriptor in recoverable {
2002 self.emit_event(Event::media_resend_required(
2003 descriptor.file_id.clone(),
2004 descriptor.recipient.clone(),
2005 descriptor.file_name.clone(),
2006 descriptor.file_size,
2007 ));
2008 }
2009
2010 Ok(())
2011 }
2012
2013 /// Stops the protocol gracefully.
2014 ///
2015 /// # Returns
2016 ///
2017 /// Returns `Ok(())` if stopped successfully, `Err` if not started.
2018 pub fn stop(&mut self) -> Result<()> {
2019 let state = lock_shared_state(&self.shared_state)?;
2020
2021 if state.state == ProtocolState::Stopped {
2022 return Ok(()); // Already stopped
2023 }
2024
2025 // Flush debounced Lamport clock before stopping so no ticks are lost.
2026 drop(state);
2027 self.flush_lamport_clock();
2028 // Same for the Nostr receive watermark: an un-flushed tail costs the
2029 // next launch a wider replay window.
2030 self.flush_nostr_watermark();
2031 // And the deduplicator's seen set: an un-flushed tail is a message
2032 // whose second copy the next launch fails to recognise.
2033 self.flush_dedup_seen();
2034 // Answer any lookup still in flight while the transport is still up to
2035 // take the cancellations. Nothing pumps relays or sweeps deadlines once
2036 // stopped, so a resolution left here is an event that never comes.
2037 self.flush_username_resolutions_for_stop();
2038
2039 // Clear event callbacks to release shared_state references.
2040 // NOTE: the routing-decision callback is deliberately NOT cleared
2041 // here. Its lifetime is sink-scoped (installed by
2042 // `install_telemetry_sink`, replaced by a subsequent install),
2043 // not protocol-running-scoped — so a `stop() → start()` cycle
2044 // preserves the wiring without requiring the app to re-install.
2045 self.transport_manager.set_dors_event_callback(None);
2046 if let Some(ble_arc) = self.transport_manager.get_transport(TransportType::BLE) {
2047 if let Some(ble) = ble_arc.as_any().downcast_ref::<BleTransport>() {
2048 ble.set_fragment_eviction_callback(None);
2049 }
2050 }
2051
2052 self.transport_manager.stop()?;
2053 let mut state = lock_shared_state(&self.shared_state)?;
2054
2055 state.state = ProtocolState::Stopped;
2056
2057 Ok(())
2058 }
2059
2060 /// Pauses the protocol (for background mode).
2061 pub fn pause(&mut self) -> Result<()> {
2062 self.flush_lamport_clock();
2063 self.flush_nostr_watermark();
2064 let mut state = lock_shared_state(&self.shared_state)?;
2065
2066 if state.state != ProtocolState::Running {
2067 return Err(Error::NotStarted);
2068 }
2069
2070 state.state = ProtocolState::Paused;
2071 Ok(())
2072 }
2073
2074 /// Resumes the protocol from pause.
2075 pub fn resume(&mut self) -> Result<()> {
2076 {
2077 let mut state = lock_shared_state(&self.shared_state)?;
2078
2079 if state.state != ProtocolState::Paused {
2080 return Err(Error::InvalidConfiguration(
2081 "Protocol is not paused".to_string(),
2082 ));
2083 }
2084
2085 state.state = ProtocolState::Running;
2086 }
2087
2088 // A pause is the other edge back into a live event pipeline, so it owes
2089 // the same drain `start()` does. `settle_restored_message_failure` parks
2090 // anything it produces while the protocol is not `Running`, and
2091 // `update_retry_config` reaches it at runtime: shortening
2092 // `pending_message_max_lifetime_ms` in the background expires queued
2093 // messages and parks their terminal `message_failed`. Without this the
2094 // app would hold those ids until a `start()` that may never come again.
2095 self.drain_deferred_restore_settlements();
2096 Ok(())
2097 }
2098
2099 /// Emits every terminal settlement parked while the event pipeline was not
2100 /// live, and reports the count of any the cap dropped.
2101 ///
2102 /// Called from both edges into `Running` ([`Self::start`] and
2103 /// [`Self::resume`]) so a parked settlement has no state to be stranded in.
2104 /// Idempotent: draining an empty queue emits nothing.
2105 fn drain_deferred_restore_settlements(&mut self) {
2106 for event in std::mem::take(&mut self.deferred_restore_settlements) {
2107 self.emit_event(event);
2108 }
2109 let suppressed = std::mem::take(&mut self.suppressed_restore_settlements);
2110 if suppressed > 0 {
2111 warn!(
2112 suppressed,
2113 cap = MAX_DEFERRED_RESTORE_SETTLEMENTS,
2114 "Restore produced more terminal settlements than any legitimate run can produce; \
2115 the ids past the cap were not reported individually"
2116 );
2117 }
2118 }
2119
2120 /// Forget that a peer replicates documents.
2121 ///
2122 /// The capability and the offer window are two halves of one fact, and
2123 /// they have to be dropped together: a window left behind outlives the
2124 /// capability and then suppresses the first offer to the same peer once
2125 /// it is relearned, which is a document that silently does not sync.
2126 pub(crate) fn forget_data_sync_peer(&mut self, peer: &str) {
2127 self.peer_data_sync.remove(peer);
2128 self.peer_data_group.remove(peer);
2129 self.peer_data_group_attested.remove(peer);
2130 self.peer_data_media.remove(peer);
2131 // A peer we have stopped replicating with cannot answer anything we
2132 // asked them for, so the questions go too. Left behind they would
2133 // hold slots against the fetch bound until they timed out.
2134 #[cfg(feature = "data")]
2135 self.forget_attachment_state(peer);
2136 #[cfg(feature = "data")]
2137 self.forget_data_sync_offer_windows(peer);
2138 }
2139
2140 /// [`Self::forget_data_sync_peer`] for every peer at once.
2141 pub(crate) fn forget_every_data_sync_peer(&mut self) {
2142 self.peer_data_sync.clear();
2143 self.peer_data_group.clear();
2144 self.peer_data_group_attested.clear();
2145 self.peer_data_media.clear();
2146 // Reported, not merely dropped, exactly as the single-peer road
2147 // reports. Nothing the application did reaches this one: the bound
2148 // on remembered peers is hit, a stranger's key package forgets every
2149 // capability at once, and a fetch outstanding at that moment ends
2150 // for a reason its asker has no way to see. Dropping the record
2151 // silently also forecloses the expiry that would have reported it
2152 // later, so the spinner this event exists to end never ends at all.
2153 #[cfg(feature = "data")]
2154 self.end_every_pending_fetch();
2155 #[cfg(not(feature = "data"))]
2156 self.pending_attachment_fetches.clear();
2157 self.blob_request_windows.clear();
2158 #[cfg(feature = "data")]
2159 self.last_data_sync_offer.clear();
2160 }
2161
2162 /// Called when a new neighbor is discovered.
2163 ///
2164 /// When auto key exchange is enabled, this method sends our key package
2165 /// to the newly discovered peer to enable encrypted communication.
2166 ///
2167 /// # Arguments
2168 ///
2169 /// * `peer_id` - The ID of the discovered peer
2170 pub fn on_neighbor_discovered(&mut self, peer_id: &str) {
2171 self.on_neighbor_discovered_via(peer_id, None);
2172 }
2173
2174 /// [`Self::on_neighbor_discovered`] with an optional transport override
2175 /// for the outbox flush (see [`Self::flush_outbox_for_peer_via`]).
2176 /// `unpark_via` is `Some(Internet)` when the relay presence-online edge
2177 /// re-enters discovery through its rescue branch: a message whose
2178 /// forced-internet re-drive just failed sits in the retry queue with no
2179 /// pending ACK, so this inner flush would pick it up again — and
2180 /// without the override DORS could route it into the mesh void with the
2181 /// park counter already cleared, past re-park's reach.
2182 fn on_neighbor_discovered_via(&mut self, peer_id: &str, unpark_via: Option<TransportType>) {
2183 // Don't track ourselves
2184 if peer_id == self.local_id {
2185 return;
2186 }
2187
2188 // Don't track or auto-exchange keys with blocked users
2189 if self.is_user_blocked(peer_id) {
2190 debug!(peer_id = %peer_id, "Ignoring neighbor discovery for blocked user");
2191 return;
2192 }
2193
2194 // Track discovered peers for service discovery and routing. Existing
2195 // peers get their last-seen refreshed; a new peer at capacity evicts
2196 // the least-recently-seen entry so a genuinely present neighbor is
2197 // never locked out by stale message-path senders (issue #140).
2198 if let Some(last_seen) = self.known_peers.get_mut(peer_id) {
2199 *last_seen = Instant::now();
2200 } else {
2201 if self.known_peers.len() >= MAX_KNOWN_PEERS {
2202 if let Some(victim) = self
2203 .known_peers
2204 .iter()
2205 .min_by_key(|(_, seen)| **seen)
2206 .map(|(id, _)| id.clone())
2207 {
2208 debug!(peer_id = %peer_id, evicted = %victim, cap = MAX_KNOWN_PEERS, "Known peers at capacity, evicting least-recently-seen");
2209 self.evict_known_peer(&victim);
2210 }
2211 }
2212 self.known_peers.insert(peer_id.to_string(), Instant::now());
2213 }
2214
2215 // Flush any pending outbox messages destined for this peer
2216 self.flush_outbox_for_peer_via(peer_id, unpark_via);
2217
2218 // A Welcome that stalled or expired while this peer was unreachable now
2219 // has a fresh delivery opportunity over the carrier that surfaced this
2220 // peer — re-arm it. No-op when there is no pending Welcome for the peer.
2221 self.rearm_welcome_for_peer(peer_id, "peer_rediscovered");
2222
2223 // Offer document versions while the peer is in front of us. This has
2224 // to sit above the key-package guard below, which returns early for
2225 // any peer we have already exchanged with — that is to say, for
2226 // exactly the established peers whose documents drifted apart during
2227 // the partition this discovery just ended.
2228 #[cfg(feature = "data")]
2229 self.kick_data_sync(peer_id, "peer_rediscovered");
2230
2231 // The same reconciliation for every group space shared with this
2232 // peer. A group space cannot ride the 1:1 trigger above: two
2233 // members of a group need not have a session with each other at
2234 // all, so for them this discovery is the only moment anything knows
2235 // they are both reachable.
2236 #[cfg(feature = "data")]
2237 self.kick_shared_group_data_sync(peer_id, "peer_rediscovered");
2238
2239 // Only send key package if encryption is enabled and auto key exchange is on
2240 if !self.config.encryption.enabled || !self.config.encryption.auto_key_exchange {
2241 return;
2242 }
2243
2244 // Only send once per peer
2245 if self.key_package_sent_to.contains(peer_id) {
2246 return;
2247 }
2248
2249 // Only if MLS is initialized
2250 if self.mls_manager.is_none() {
2251 return;
2252 }
2253
2254 if let Err(e) = self.send_key_package_to(peer_id, false) {
2255 warn!(error = %e, peer_id = %peer_id, "Failed to send key package on discovery");
2256 }
2257 }
2258
2259 /// Immediately attempts to send all pending outbox messages destined
2260 /// for a specific peer, bypassing backoff timers. Called on per-peer
2261 /// reachability edges (discovery, relay presence-online) to flush
2262 /// messages that were queued while the peer was unreachable.
2263 ///
2264 /// `unpark_via` is an optional transport override for the re-driven
2265 /// sends: `Some(Internet)` on the relay presence-online edge
2266 /// (`on_peer_presence`, including its rescue branch's re-entry through
2267 /// `on_neighbor_discovered_via`): the reachability
2268 /// proof is relay-scoped, so the re-drive must go out over the internet
2269 /// transport — DORS could otherwise route it back into the mesh, where
2270 /// the send locally succeeds against a peer that is not there,
2271 /// re-registering an unanswerable ACK that re-strands the message (and,
2272 /// with the platform bridge unwatching the peer on the online answer,
2273 /// no further presence edge would arrive to save it). Pinned media
2274 /// transports still win over the override.
2275 fn flush_outbox_for_peer_via(&mut self, peer_id: &str, unpark_via: Option<TransportType>) {
2276 // Probe ACKs first: with a live park counter, the peer's DMs may be
2277 // mid-probe — awaiting a probe ACK that may never arrive (never, over
2278 // a mesh carrier) — and the awaiting-ACK filter below would skip
2279 // exactly the messages this edge exists to re-drive.
2280 self.cancel_probe_state_for_parked_peer(peer_id);
2281 // A per-peer reachability edge: the escalating unreachable-probe
2282 // interval starts over (mirrors rearm_welcome_for_peer resetting
2283 // unreachable_parks). Snapshotted, not dropped: an edge whose
2284 // re-drives all fail didn't actually take, and restores it below.
2285 let prior_parks = self.dm_unreachable_parks.remove(peer_id);
2286
2287 // Collect matching messages from outbox + media_outbox
2288 let mut to_send: Vec<(Message, u32)> = Vec::new();
2289
2290 for entry in self.outbox.values() {
2291 if entry.message.recipient.as_str() == peer_id
2292 && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
2293 {
2294 to_send.push((entry.message.clone(), entry.attempt_count));
2295 }
2296 }
2297 for entry in self.media_outbox.values() {
2298 if entry.message.recipient.as_str() == peer_id
2299 && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
2300 {
2301 to_send.push((entry.message.clone(), entry.attempt_count));
2302 }
2303 }
2304
2305 if to_send.is_empty() {
2306 return;
2307 }
2308
2309 let count = to_send.len().min(crate::constants::FLUSH_BATCH_LIMIT);
2310 debug!(peer_id = %peer_id, count = count, "Flushing outbox for discovered peer");
2311
2312 let mut iter = to_send.into_iter();
2313 let mut parked_dm_seen = false;
2314 let mut parked_dm_redriven = false;
2315
2316 // Only process up to FLUSH_BATCH_LIMIT messages per edge.
2317 for (message, attempt_count) in iter.by_ref().take(crate::constants::FLUSH_BATCH_LIMIT) {
2318 let parkable = self.is_parkable_plain_dm(&message.id);
2319 // Remove from retry queue since we're sending immediately
2320 self.retry_queue.remove(&message.id.as_str());
2321 let sent = self.try_flush_send_via(message, attempt_count, unpark_via);
2322 if parkable {
2323 parked_dm_seen = true;
2324 parked_dm_redriven |= sent.is_some();
2325 }
2326 }
2327
2328 // Re-enqueue the overflow (mirrors flush_outbox_all): the probe
2329 // cancel above may have stripped a parked message's retry entry, so
2330 // "still holds its backoff timer" cannot be assumed past the batch
2331 // limit. Enqueue dedupes by id — entries still scheduled keep their
2332 // existing timer, canceled ones get a fresh backoff slot instead of
2333 // stranding edge-only with the park counter cleared.
2334 for (message, attempt_count) in iter {
2335 parked_dm_seen = parked_dm_seen || self.is_parkable_plain_dm(&message.id);
2336 let _ = self.retry_queue.enqueue(message, attempt_count);
2337 }
2338
2339 // An edge that re-drove no parkable DM at all didn't actually take:
2340 // the DMs now sit in the retry queue, whose later sends are
2341 // DORS-routed (no transport override there), and a send that succeeds
2342 // locally without reaching the peer would re-register an ACK nothing
2343 // will answer. Restore the counter so exhaustion stays within
2344 // `try_repark_exhausted_dm`'s reach instead of settling terminally
2345 // with the counter cleared.
2346 //
2347 // Restore granularity is per-peer, matching the counter: one
2348 // successful re-drive clears it even when a sibling's send failed on
2349 // the same edge, leaving that sibling on DORS-routed backoff exposed
2350 // to terminal exhaustion. Accepted gap — it requires one transport
2351 // to both succeed and fail within a single flush loop.
2352 if let Some(parks) = prior_parks {
2353 if parked_dm_seen && !parked_dm_redriven {
2354 self.dm_unreachable_parks.insert(peer_id.to_string(), parks);
2355 }
2356 }
2357 }
2358
2359 /// Immediately attempts to send all pending outbox messages across all peers.
2360 ///
2361 /// Called when a transport becomes available (e.g. internet reconnects) to
2362 /// flush all queued messages, bypassing backoff timers.
2363 pub fn flush_outbox_all(&mut self) {
2364 // Probe ACKs first (see `flush_outbox_for_peer_via`): mid-probe DMs
2365 // are awaiting unanswerable mesh ACKs, and the awaiting-ACK guard
2366 // below would strand them past this edge with their counters
2367 // cleared. Canceled entries surface through the stranded-outbox
2368 // collection.
2369 let parked_peers: Vec<String> = self.dm_unreachable_parks.keys().cloned().collect();
2370 for peer_id in parked_peers {
2371 self.cancel_probe_state_for_parked_peer(&peer_id);
2372 }
2373 // A carrier-level reachability edge (reconnect / start): every
2374 // escalating unreachable-probe interval starts over. Snapshotted,
2375 // not dropped: a peer whose re-drives all fail gets its counter
2376 // restored below (see `flush_outbox_for_peer_via`).
2377 let prior_parks = std::mem::take(&mut self.dm_unreachable_parks);
2378
2379 // Drain all entries from the retry queue (ignores timing)
2380 let retry_entries = self.retry_queue.drain_all();
2381
2382 // Also collect outbox entries NOT in the retry queue (stranded after
2383 // previous max_retries rejection)
2384 let mut all_messages: Vec<(Message, u32)> = retry_entries
2385 .into_iter()
2386 .map(|e| (e.message, e.retry_count))
2387 .collect();
2388
2389 // Add outbox entries that weren't in the retry queue (stranded) AND
2390 // aren't already waiting for an ACK (successfully sent, just awaiting
2391 // confirmation). Without the ACK check, a prior flush that succeeded
2392 // would cause these messages to be re-sent unnecessarily.
2393 let retry_ids: std::collections::HashSet<String> =
2394 all_messages.iter().map(|(m, _)| m.id.as_str()).collect();
2395
2396 for entry in self.outbox.values() {
2397 if !retry_ids.contains(&entry.message.id.as_str())
2398 && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
2399 {
2400 all_messages.push((entry.message.clone(), entry.attempt_count));
2401 }
2402 }
2403 for entry in self.media_outbox.values() {
2404 if !retry_ids.contains(&entry.message.id.as_str())
2405 && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
2406 {
2407 all_messages.push((entry.message.clone(), entry.attempt_count));
2408 }
2409 }
2410
2411 // Opt-in (`edge_driven_unreachable_dm`): on the start/reconnect re-drive,
2412 // leave durably-failing messages (first sent longer ago than
2413 // OUTBOX_DURABLE_UNREACHABLE_AGE_SECS, still undelivered) edge-driven
2414 // rather than re-driving them, so a restart does not re-ramp a large
2415 // dead-peer backlog. They rest in the outbox and flush the instant their
2416 // peer next proves reachable. Default-off preserves the documented
2417 // "reconnect re-drives, ignoring timing" behaviour for every existing
2418 // app. drain_all() already removed these from the retry queue and the
2419 // outbox entry is untouched, so "not collected" == "resting edge-driven".
2420 if self.config.reliability.retry.edge_driven_unreachable_dm {
2421 let now = Utc::now();
2422 let durable_age = chrono::Duration::seconds(
2423 crate::protocol::types::OUTBOX_DURABLE_UNREACHABLE_AGE_SECS,
2424 );
2425 all_messages.retain(|(m, _)| {
2426 match self
2427 .outbox
2428 .get(&m.id)
2429 .or_else(|| self.media_outbox.get(&m.id))
2430 .map(|e| e.first_sent_at)
2431 {
2432 Some(first_sent) => now.signed_duration_since(first_sent) < durable_age,
2433 None => true,
2434 }
2435 });
2436 }
2437
2438 if all_messages.is_empty() {
2439 return;
2440 }
2441
2442 let count = all_messages.len().min(crate::constants::FLUSH_BATCH_LIMIT);
2443 debug!(
2444 count = count,
2445 total = all_messages.len(),
2446 "Flushing all outbox messages"
2447 );
2448
2449 let mut iter = all_messages.into_iter();
2450 let mut parked_dm_seen: std::collections::HashSet<String> =
2451 std::collections::HashSet::new();
2452 let mut parked_dm_redriven: std::collections::HashSet<String> =
2453 std::collections::HashSet::new();
2454
2455 for (message, attempt_count) in iter.by_ref().take(crate::constants::FLUSH_BATCH_LIMIT) {
2456 let recipient = message.recipient.as_str().to_string();
2457 let parkable =
2458 prior_parks.contains_key(&recipient) && self.is_parkable_plain_dm(&message.id);
2459 let sent_via = self.try_flush_send(message, attempt_count);
2460 if parkable {
2461 // Unlike the per-peer edges (discovery carries mesh-scoped
2462 // proof, presence-online forces the answering carrier), this
2463 // carrier-level edge re-drives via DORS with no per-peer
2464 // reachability proof: a mesh-routed send "locally succeeds"
2465 // into the mesh whether or not the peer is there, so only a
2466 // send over a carrier that answers verdicts counts as
2467 // re-driven for counter-clearing — that is the send whose
2468 // failure would say something. A mesh-routed success keeps
2469 // the counter restore-eligible; if its ACK genuinely
2470 // arrives, delivery prunes the counter.
2471 if sent_via.and_then(GatewayCarrier::from_transport).is_some() {
2472 parked_dm_redriven.insert(recipient);
2473 } else {
2474 parked_dm_seen.insert(recipient);
2475 }
2476 }
2477 }
2478
2479 // Re-enqueue any messages beyond the batch limit so they aren't lost
2480 for (message, attempt_count) in iter {
2481 let recipient = message.recipient.as_str();
2482 if prior_parks.contains_key(recipient) && self.is_parkable_plain_dm(&message.id) {
2483 parked_dm_seen.insert(recipient.to_string());
2484 }
2485 let _ = self.retry_queue.enqueue(message, attempt_count);
2486 }
2487
2488 // Restore the counter of every parked peer none of whose DMs were
2489 // re-driven on this edge (see `flush_outbox_for_peer_via`): their
2490 // retry-queue sends are DORS-routed, and a send that succeeds locally
2491 // without reaching the peer must leave exhaustion re-parkable rather
2492 // than terminal.
2493 for (peer_id, parks) in prior_parks {
2494 if parked_dm_seen.contains(&peer_id) && !parked_dm_redriven.contains(&peer_id) {
2495 self.dm_unreachable_parks.insert(peer_id, parks);
2496 }
2497 }
2498 }
2499
2500 /// Records what the attached Reticulum gateway says it can do.
2501 ///
2502 /// Wholesale replace, like the relay's: each attach describes the gateway
2503 /// actually connected now. The bridge calls this **before** it reports the
2504 /// carrier available, which is the contract's own ordering — a device must
2505 /// not drain into a gateway whose features it has not been told — and it
2506 /// clears the set when the carrier drops.
2507 ///
2508 /// The list is wire-supplied, so it is bounded exactly as the relay's is,
2509 /// which is what the contract points at rather than inventing a second
2510 /// pair of numbers: at most 64 tokens of at most 128 bytes each, the same
2511 /// `MAX_RELAY_CAPABILITIES` and `MAX_RELAY_CAPABILITY_TOKEN_BYTES` the
2512 /// relay's advertisement is bounded by. Oversized tokens are
2513 /// dropped *before* the count is applied, so padding cannot evict real
2514 /// ones.
2515 ///
2516 /// Nothing here gates a security decision. Tokens gate features, and an
2517 /// attacker who can set them is already the gateway.
2518 pub fn set_gateway_capabilities(&mut self, capabilities: Vec<String>) {
2519 let caps: HashSet<String> = capabilities
2520 .into_iter()
2521 .filter(|c| !c.is_empty() && c.len() <= MAX_RELAY_CAPABILITY_TOKEN_BYTES)
2522 .take(MAX_RELAY_CAPABILITIES)
2523 .collect();
2524 debug!(count = caps.len(), "Gateway capabilities updated");
2525 self.gateway_capabilities = caps;
2526 }
2527
2528 /// Forgets the attached gateway's advertisement, on carrier drop.
2529 pub fn clear_gateway_capabilities(&mut self) {
2530 if !self.gateway_capabilities.is_empty() {
2531 debug!("Gateway capabilities cleared on carrier drop");
2532 self.gateway_capabilities.clear();
2533 }
2534 }
2535
2536 /// The attached gateway's advertised capability tokens, sorted so the
2537 /// order a caller sees does not depend on hashing.
2538 pub fn gateway_capabilities(&self) -> Vec<String> {
2539 let mut tokens: Vec<String> = self.gateway_capabilities.iter().cloned().collect();
2540 tokens.sort();
2541 tokens
2542 }
2543
2544 /// Peers the platform layer should watch via a gateway `CheckPresence`
2545 /// query: every peer with an undelivered or session-unproven MLS welcome
2546 /// ([`Self::welcome_pending_peers`]) plus every recipient of an outbox
2547 /// message not currently awaiting an ACK — which includes DMs parked on a
2548 /// `recipient_unreachable` verdict. A gateway's presence-online answer
2549 /// lands in [`Self::on_peer_presence_via`], whose
2550 /// `flush_outbox_for_peer_via` is what re-drives (un-parks) those
2551 /// messages, so the SDK — not the app — owns the "watch my DeliveryError
2552 /// recipients" duty. Bounded by the outbox cap (500 entries).
2553 ///
2554 /// Carrier-agnostic on purpose: the set is "who this device is waiting to
2555 /// hear about", which does not depend on who is asked. Every attached
2556 /// gateway watches the same peers.
2557 pub fn presence_watch_peers(&self) -> Vec<String> {
2558 let mut peers = self.welcome_pending_peers();
2559 let mut seen: std::collections::HashSet<String> = peers.iter().cloned().collect();
2560 for entry in self.outbox.values() {
2561 if self.ack_manager.is_waiting_for_ack(&entry.message.id) {
2562 continue;
2563 }
2564 let recipient = entry.message.recipient.as_str();
2565 if seen.insert(recipient.to_string()) {
2566 peers.push(recipient.to_string());
2567 }
2568 }
2569 peers
2570 }
2571
2572 /// Attempts to send a single message as part of a flush operation.
2573 ///
2574 /// Ensures the outbox entry exists before sending (it may have been evicted
2575 /// by capacity limits while the message sat in the retry queue). On success,
2576 /// registers ACK tracking and updates the outbox entry. On failure,
2577 /// re-enqueues the message to the retry queue with its current attempt count
2578 /// so backoff resumes. Returns the transport the send went out over, or
2579 /// `None` when it failed (or, defensively, when the transport could not
2580 /// be attributed — the park restore logic treats both as not-redriven,
2581 /// the safe direction; in practice a successful send always has one).
2582 fn try_flush_send(&mut self, message: Message, attempt_count: u32) -> Option<TransportType> {
2583 self.try_flush_send_via(message, attempt_count, None)
2584 }
2585
2586 /// [`Self::try_flush_send`] with an optional transport override (see
2587 /// [`Self::flush_outbox_for_peer_via`]). A pinned media transport takes
2588 /// precedence over the override; without either, DORS picks.
2589 fn try_flush_send_via(
2590 &mut self,
2591 mut message: Message,
2592 attempt_count: u32,
2593 transport_override: Option<TransportType>,
2594 ) -> Option<TransportType> {
2595 self.ensure_outbox_entry(&message);
2596 // Tier 2: re-seal against the peer's current session before flushing, so
2597 // a post-re-key epoch change no longer leaves the bytes undecryptable.
2598 // No-op for media/plaintext/unconfirmed sessions.
2599 self.reseal_for_resend_in_place(&mut message);
2600 let forced_transport = self
2601 .pinned_media_transport_for_message(&message.id)
2602 .or(transport_override);
2603 let send_result = if let Some(transport) = forced_transport {
2604 self.transport_manager
2605 .send_via_transport(&message, transport)
2606 } else {
2607 self.transport_manager.send(&message)
2608 };
2609 let current_transport =
2610 forced_transport.or_else(|| self.transport_manager.current_transport());
2611
2612 match send_result {
2613 Ok(()) => {
2614 if let Err(e) = self.ensure_ack_registration(&message) {
2615 warn!(message_id = %message.id, error = %e, "ACK registration failed during flush");
2616 }
2617 self.mark_message_sent(
2618 &message,
2619 current_transport,
2620 Some(attempt_count.saturating_add(1)),
2621 );
2622 debug!(message_id = %message.id, "Flush send succeeded");
2623 current_transport
2624 }
2625 Err(e) => {
2626 let _ = self.retry_queue.enqueue(message.clone(), attempt_count);
2627 debug!(message_id = %message.id, error = %e, "Flush send failed, re-enqueued");
2628 None
2629 }
2630 }
2631 }
2632
2633 /// Called when a neighbor is lost.
2634 ///
2635 /// Cleans up tracking state for the lost peer.
2636 ///
2637 /// # Arguments
2638 ///
2639 /// * `peer_id` - The ID of the lost peer
2640 pub fn on_neighbor_lost(&mut self, peer_id: &str) {
2641 self.evict_known_peer(peer_id);
2642 }
2643
2644 /// Drops a peer from discovery tracking. Shared by explicit neighbor
2645 /// loss, the TTL sweep, and least-recently-seen eviction at capacity —
2646 /// all three mean "treat this peer as gone until re-seen", so all three
2647 /// also clear the key-package marker, letting a re-appearing peer
2648 /// receive a fresh key package exactly as on a BLE reconnect.
2649 fn evict_known_peer(&mut self, peer_id: &str) {
2650 self.key_package_sent_to.remove(peer_id);
2651 self.known_peers.remove(peer_id);
2652 // Release the forwarding allowance held for this link. A peer that
2653 // reconnects starts with a full allowance, which is correct: the limit
2654 // exists to pace a live link, not to punish one that came back.
2655 self.mesh_relay.forget_peer(peer_id);
2656 }
2657
2658 /// Tears down the 1:1 session with `peer_id` and rebuilds it, advancing
2659 /// post-compromise security.
2660 ///
2661 /// # Why an application has to ask
2662 ///
2663 /// Post-compromise security arrives when a commit rotates a member's leaf
2664 /// in the ratchet tree. This engine originates one on a re-key, and until
2665 /// now the only thing that drove a re-key was an **epoch desync**: a
2666 /// healthy pair that never forked never rotated, so the window an attacker
2667 /// holding old key material keeps open was bounded by nothing.
2668 ///
2669 /// That is felt hardest against a leaf node, which never commits at all, so
2670 /// every rotation in such a pair is the phone's to originate. It is not
2671 /// exclusive to one: two phones that simply never desync are in the same
2672 /// position.
2673 ///
2674 /// The cadence is the application's because this engine cannot pick one.
2675 /// A rotation costs a teardown, a key-package exchange and a re-establish,
2676 /// and what that is worth depends on the deployment: a door lock on mains
2677 /// power and a phone on a metered link do not want the same interval, and
2678 /// nothing on the wire says which one is on the other end.
2679 ///
2680 /// # What the peer sees
2681 ///
2682 /// Exactly what a desync-driven re-key sends, because it is the same code:
2683 /// a `__MLS_KEY_PKG__` frame carrying `session_reset`. A peer running this
2684 /// engine discards its stale session and converges in one round trip; a
2685 /// leaf node discards its session, mints a fresh key package and re-pairs.
2686 /// Queued plaintext survives, because it is sealed at flush time against
2687 /// whatever session is current then.
2688 ///
2689 /// # Rate limit
2690 ///
2691 /// Shared with the desync path, one re-key per peer per window. A caller
2692 /// looping on this cannot do to a pair what that bound exists to stop an
2693 /// attacker doing.
2694 ///
2695 /// # Returns
2696 ///
2697 /// * `Ok(true)` - the reset was advertised and the session torn down.
2698 /// * `Ok(false)` - the rate-limit window has not lapsed; nothing was done,
2699 /// and a later call succeeds. Not an error: a caller on a fixed schedule
2700 /// that briefly runs faster than the floor is behaving correctly.
2701 ///
2702 /// # A rotation that fails changes nothing
2703 ///
2704 /// The reset is advertised **before** the local session is torn down, so
2705 /// an `Err` leaves the pair exactly as it was: the session is still there,
2706 /// still usable, and the rate-limit window is not spent. Rotate while the
2707 /// peer is reachable, and treat a failure as "try again later" rather than
2708 /// as a session to rebuild.
2709 ///
2710 /// The order matters most against a leaf node, which speaks only when
2711 /// spoken to. Tearing down first and failing to send would leave this side
2712 /// with no session, the device holding one it will never be told to drop,
2713 /// and nothing on either side that re-opens the exchange.
2714 ///
2715 /// # Errors
2716 ///
2717 /// * [`Error::MlsNotInitialized`] - encryption is not set up.
2718 /// * [`Error::UserBlocked`] - the peer is blocked.
2719 /// * [`Error::Mls`] with `SessionNotFound` - there is no session to rebuild.
2720 /// Establishing a first session is [`Self::establish_secure_session`];
2721 /// this heals one that exists.
2722 /// * [`Error::Transport`] - no transport accepted the reset frame. The
2723 /// session is untouched, as above.
2724 pub fn rekey_session(&mut self, peer_id: &str) -> Result<bool> {
2725 if self.is_user_blocked(peer_id) {
2726 return Err(Error::UserBlocked(peer_id.to_string()));
2727 }
2728 if self.mls_manager.is_none() {
2729 return Err(Error::MlsNotInitialized);
2730 }
2731 // A session has to exist for there to be one to heal. Checked before
2732 // the slot is claimed, so a refused call does not spend the window and
2733 // leave a real re-key waiting on it.
2734 if !self.has_mls_session(peer_id)? {
2735 return Err(Error::Mls(offline_protocol_mls::MlsError::SessionNotFound(
2736 peer_id.to_string(),
2737 )));
2738 }
2739 if !self.claim_rekey_slot(peer_id) {
2740 debug!(
2741 peer_id = %peer_id,
2742 "rekey_session: the rate-limit window has not lapsed"
2743 );
2744 return Ok(false);
2745 }
2746 if let Err(err) = self.drive_session_rekey(peer_id, "requested by the application") {
2747 // Nothing was sent and nothing was torn down, so nothing was
2748 // spent: a caller back in range a second from now is asking for a
2749 // rotation this pair has not had.
2750 self.release_rekey_slot(peer_id);
2751 return Err(err);
2752 }
2753 Ok(true)
2754 }
2755
2756 /// Establishes a secure MLS session with a peer.
2757 ///
2758 /// This high-level method handles the complete session establishment flow:
2759 /// 1. Checks if a session already exists (returns Ok(None) if so)
2760 /// 2. Checks for a pending key package from the peer
2761 /// 3. If found, imports the key package, creates the session, and sends the Welcome message
2762 /// 4. If no key package is available, returns an error
2763 ///
2764 /// This method is designed for use by application code that needs explicit control
2765 /// over session establishment, as opposed to the automatic encryption flow.
2766 ///
2767 /// # Arguments
2768 ///
2769 /// * `peer_id` - The ID of the peer to establish a session with
2770 ///
2771 /// # Returns
2772 ///
2773 /// * `Ok(Some(WelcomeMessage))` - Session created, Welcome message returned (and sent to peer)
2774 /// * `Ok(None)` - Session already exists
2775 /// * `Err(SessionNotReady(state))` - Establishment in progress; caller can retry or show "Establishing…"
2776 pub fn establish_secure_session(&mut self, peer_id: &str) -> Result<Option<WelcomeMessage>> {
2777 if self.is_user_blocked(peer_id) {
2778 return Err(Error::UserBlocked(peer_id.to_string()));
2779 }
2780
2781 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2782
2783 // Check if session already exists
2784 let has_session = {
2785 let manager = mls
2786 .read()
2787 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2788 manager.has_session(peer_id)?
2789 };
2790
2791 if has_session {
2792 debug!(peer_id = %peer_id, "Session already exists");
2793 return Ok(None);
2794 }
2795
2796 // Try loading key package from storage (e.g. after restart) before giving up
2797 self.try_load_key_package_from_storage_into_memory(peer_id);
2798
2799 // Check for pending key package (memory, possibly just restored from storage)
2800 // Clone first, only remove after all operations succeed to avoid losing the key package on failure
2801 if let Some(received_pkg) = self.pending_key_packages.get(peer_id).cloned() {
2802 // Check if key package has expired (using local clock)
2803 let now_ms = Utc::now().timestamp_millis() as u64;
2804 if now_ms >= received_pkg.local_expires_at_ms {
2805 warn!(peer_id = %peer_id, "Received key package has expired, discarding");
2806 self.pending_key_packages.remove(peer_id);
2807 self.delete_peer_key_package_from_storage(peer_id);
2808 } else {
2809 {
2810 let manager = mls
2811 .read()
2812 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2813 manager.import_key_package(peer_id, &received_pkg.key_package_data)?;
2814 }
2815
2816 // Create session and get welcome message
2817 let welcome = {
2818 let manager = mls
2819 .read()
2820 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2821 manager.create_session(peer_id)?
2822 };
2823 // The handshake starts here. This is the auto-establish path
2824 // taken the moment a peer's key package arrives, which is how
2825 // an ordinary session begins and which raises no
2826 // `session_missing` at all; see `emit_mls_session_establishing`.
2827 self.emit_mls_session_establishing(
2828 peer_id,
2829 welcome.group_id.as_str(),
2830 crate::mls_observability::MlsOperationContext::Welcome,
2831 );
2832 // We only ever build a session from a key package the peer
2833 // published, so reaching here proves they run MLS.
2834 self.mark_encryption_capable(peer_id);
2835
2836 // Send welcome message to peer
2837 let welcome_sent = self.send_welcome_message(peer_id, &welcome)?;
2838
2839 // All operations succeeded, now safe to remove the key package
2840 self.pending_key_packages.remove(peer_id);
2841 self.delete_peer_key_package_from_storage(peer_id);
2842
2843 let group_id = welcome.group_id.as_str().to_string();
2844 let is_session = group_id.starts_with("session:");
2845 if let Err(err) = self.ensure_session_state_entry(peer_id, "session_created_local")
2846 {
2847 warn!(
2848 peer_id = %peer_id,
2849 error = %err,
2850 "Failed to persist pending session state"
2851 );
2852 }
2853
2854 info!(peer_id = %peer_id, group_id = %group_id, "Established secure session");
2855
2856 // Send a fresh key package so the peer has one available for
2857 // group invites (the original was consumed to create this session).
2858 if self.config.encryption.enabled {
2859 let _ = self.send_key_package_to(peer_id, false);
2860 }
2861
2862 if welcome_sent {
2863 debug!(peer_id = %peer_id, group_id = %group_id, is_session, "Welcome synchronously sent");
2864 }
2865
2866 return Ok(Some(welcome));
2867 }
2868 }
2869
2870 // No key package available (memory nor storage) — return non-terminal state so caller can retry
2871 Err(Error::SessionNotReady(self.establishment_state(peer_id)?))
2872 }
2873
2874 /// Checks if a pending key package is available for a peer.
2875 ///
2876 /// This can be used to check if session establishment is possible
2877 /// before calling `establish_secure_session`.
2878 ///
2879 /// # Arguments
2880 ///
2881 /// * `peer_id` - The ID of the peer to check
2882 ///
2883 /// # Returns
2884 ///
2885 /// `true` if a key package is available, `false` otherwise
2886 pub fn has_pending_key_package(&self, peer_id: &str) -> bool {
2887 self.pending_key_packages.contains_key(peer_id)
2888 }
2889
2890 /// Returns the current establishment state for a peer.
2891 ///
2892 /// Use this to show "Establishing…" or drive retries without calling send/establish.
2893 pub fn get_establishment_state(&self, peer_id: &str) -> Result<EstablishmentState> {
2894 self.establishment_state(peer_id)
2895 }
2896
2897 /// Whether rich extras (reply context, rich media metadata, forward
2898 /// attribution) on a send toward `peer_id` would travel in the sealed
2899 /// rich body — i.e. the peer's last key package advertised the
2900 /// capability (surviving restarts) and our own `rich_payload_enabled`
2901 /// kill switch is on. When `false` the extras are silently dropped and
2902 /// the message degrades to plain text, so apps can use this to render
2903 /// honest degraded-reply UX instead of discovering the drop after the
2904 /// fact.
2905 pub fn peer_supports_rich_payload(&self, peer_id: &str) -> bool {
2906 self.rich_seal_active(peer_id)
2907 }
2908
2909 /// Creates a session using manually imported key material.
2910 ///
2911 /// This entrypoint is for bindings that expose low-level MLS APIs. It must
2912 /// still keep protocol-level session lifecycle state in sync with MLS state.
2913 pub fn manual_mls_create_session(&mut self, peer_id: &str) -> Result<WelcomeMessage> {
2914 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2915 let welcome = {
2916 let manager = mls
2917 .read()
2918 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2919 manager.create_session(peer_id)?
2920 };
2921 self.mark_encryption_capable(peer_id);
2922 if let Err(err) = self.ensure_session_state_entry(peer_id, "manual_session_created") {
2923 warn!(
2924 peer_id = %peer_id,
2925 error = %err,
2926 "Failed to persist pending session state after manual session create"
2927 );
2928 }
2929 Ok(welcome)
2930 }
2931
2932 /// Imports a contact's key package and records them encryption-capable.
2933 ///
2934 /// The entry point for bindings that expose low-level MLS APIs. It exists
2935 /// because the FFI wrapper used to reach straight for the `MlsManager` and
2936 /// pass the since-deleted `KeyPackageTrust::FirstUse` — so an app could
2937 /// import a key package
2938 /// under any peer id with no correlation to that peer, bypassing the check
2939 /// `establish_secure_session` performs. The identity claim no longer needs
2940 /// the detour: `import_key_package` re-derives the address from the leaf
2941 /// signature key and refuses a mismatch on its own. The routing stays
2942 /// because the capability record lives here, not on the manager.
2943 pub fn manual_mls_import_key_package(
2944 &mut self,
2945 peer_id: &str,
2946 key_package_data: &[u8],
2947 ) -> Result<()> {
2948 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2949 {
2950 let manager = mls
2951 .read()
2952 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2953 manager.import_key_package(peer_id, key_package_data)?;
2954 }
2955 // A key package we accepted for this peer is proof they run MLS.
2956 self.mark_encryption_capable(peer_id);
2957 Ok(())
2958 }
2959
2960 /// Deletes a 1:1 session and clears protocol-level lifecycle state.
2961 pub fn manual_mls_delete_session(&mut self, peer_id: &str) -> Result<()> {
2962 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2963 let manager = mls
2964 .read()
2965 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2966 manager.delete_session(peer_id)?;
2967
2968 // Apply protocol-state cleanup only after MLS deletion succeeds.
2969 self.confirmed_sessions.remove(peer_id);
2970 self.clear_confirmation_recovery_tracking(peer_id);
2971 self.welcome_lifecycles.remove(peer_id);
2972 // Drop the both-create owner gate too (memory + storage). A leaked gate
2973 // entry would make `can_confirm_from_source` reject every non-decrypt
2974 // confirmation source — including `welcome_received` — on the NEXT
2975 // session with this peer, re-stranding it in Pending (and surviving
2976 // restart via `restore_both_create_awaiting_decrypt`).
2977 self.clear_both_create_awaiting_decrypt(peer_id);
2978 self.clear_session_state_entry(peer_id)?;
2979 self.clear_welcome_lifecycle_entry(peer_id)?;
2980 Ok(())
2981 }
2982
2983 /// Joins a session from a Welcome message and synchronizes confirmation state.
2984 pub fn manual_mls_join_session(
2985 &mut self,
2986 welcome: &WelcomeMessage,
2987 ) -> Result<offline_protocol_mls::GroupInfo> {
2988 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2989 let group_info = {
2990 let manager = mls
2991 .read()
2992 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2993 manager.join_session(welcome)?
2994 };
2995 self.handle_manual_welcome_confirmation(&welcome.inviter_id);
2996 Ok(group_info)
2997 }
2998
2999 /// Processes an MLS Welcome message and synchronizes confirmation state.
3000 pub fn manual_mls_process_welcome(
3001 &mut self,
3002 welcome: &WelcomeMessage,
3003 ) -> Result<offline_protocol_mls::GroupInfo> {
3004 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
3005 let group_info = {
3006 let manager = mls
3007 .read()
3008 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
3009 manager.process_welcome(welcome)?
3010 };
3011 self.handle_manual_welcome_confirmation(&welcome.inviter_id);
3012 Ok(group_info)
3013 }
3014
3015 /// Decrypts a user-scoped MLS message and synchronizes confirmation state.
3016 pub fn manual_mls_decrypt_from_user(
3017 &mut self,
3018 encrypted: &EncryptedMessage,
3019 ) -> Result<Option<Vec<u8>>> {
3020 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
3021 let plaintext = {
3022 let manager = mls
3023 .read()
3024 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
3025 manager.decrypt_from_user(encrypted, &encrypted.sender_id)?
3026 };
3027 if plaintext.is_some() {
3028 self.handle_manual_decrypt_confirmation(&encrypted.sender_id);
3029 }
3030 Ok(plaintext)
3031 }
3032
3033 /// Decrypts any MLS message and synchronizes confirmation state for 1:1 flows.
3034 pub fn manual_mls_decrypt(&mut self, encrypted: &EncryptedMessage) -> Result<Option<Vec<u8>>> {
3035 let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
3036 let plaintext = {
3037 let manager = mls
3038 .read()
3039 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
3040 manager.decrypt(encrypted, &encrypted.sender_id)?
3041 };
3042 if plaintext.is_some() && Self::is_session_group_id(encrypted.group_id.as_str()) {
3043 self.handle_manual_decrypt_confirmation(&encrypted.sender_id);
3044 }
3045 Ok(plaintext)
3046 }
3047
3048 pub(crate) fn is_session_group_id(group_id: &str) -> bool {
3049 group_id.starts_with("session:")
3050 }
3051
3052 fn handle_manual_welcome_confirmation(&mut self, peer_id: &str) {
3053 // Shared by `manual_mls_join_session` and `manual_mls_process_welcome`:
3054 // in both cases we just joined a session this peer built for us.
3055 self.mark_encryption_capable(peer_id);
3056 match self.confirm_session_state(peer_id, "welcome_received") {
3057 Ok(true) => {
3058 let _ = self.flush_pending_messages(peer_id);
3059 self.process_pending_decryption(peer_id);
3060 }
3061 Ok(false) => {}
3062 Err(err) => {
3063 warn!(
3064 peer_id = %peer_id,
3065 error = %err,
3066 "Failed to persist session confirmation after manual welcome processing"
3067 );
3068 }
3069 }
3070 }
3071
3072 fn handle_manual_decrypt_confirmation(&mut self, peer_id: &str) {
3073 if !self.can_confirm_from_source(peer_id, "decrypt_success") {
3074 return;
3075 }
3076 match self.confirm_session_state(peer_id, "decrypt_success") {
3077 Ok(true) => {
3078 let _ = self.flush_pending_messages(peer_id);
3079 }
3080 Ok(false) => {}
3081 Err(err) => {
3082 warn!(
3083 peer_id = %peer_id,
3084 error = %err,
3085 "Failed to persist session confirmation after manual decrypt"
3086 );
3087 }
3088 }
3089 }
3090
3091 /// Processes internal MLS protocol messages.
3092 ///
3093 /// Returns `Some(InternalMessageResult::Consumed)` if the message was an internal
3094 /// protocol message that should not be surfaced to the application.
3095 /// Returns `Some(InternalMessageResult::Decrypted(plaintext))` if the message was
3096 /// encrypted and successfully decrypted.
3097 /// Returns `None` if the message is not an internal message.
3098 pub(crate) fn process_internal_message(
3099 &mut self,
3100 message: &Message,
3101 ) -> Option<InternalMessageResult> {
3102 self.process_internal_message_via(message, None)
3103 }
3104
3105 /// [`Self::process_internal_message`] with the transport the frame
3106 /// arrived on, when the caller knows it. Handlers that trust relay-server
3107 /// answers (`__GROUP_CREATED__` setting `relay_synced`) require the
3108 /// Internet transport; with `None` they treat the frame as untrusted.
3109 pub(crate) fn process_internal_message_via(
3110 &mut self,
3111 message: &Message,
3112 arrival_transport: Option<TransportType>,
3113 ) -> Option<InternalMessageResult> {
3114 let content = &message.content;
3115
3116 // Run the security gate for control messages (transport identity +
3117 // signature verification + freshness). `Rejected` drops the message;
3118 // `Proceed` carries whether the frame was actually signed, for the one
3119 // handler that consumes a payload field as authenticated data, and
3120 // whether its age was established, for the one that acts on a
3121 // directive worth replaying.
3122 let (signed, freshness_bound) =
3123 match self.security_gate_control_message(message, arrival_transport) {
3124 ControlGateOutcome::Rejected(result) => return Some(result),
3125 ControlGateOutcome::Proceed {
3126 signed,
3127 freshness_bound,
3128 } => (signed, freshness_bound),
3129 };
3130
3131 let sender = message.sender.as_str();
3132
3133 // Handle key package messages
3134 if let Some(data) = content.strip_prefix(internal_prefixes::KEY_PACKAGE) {
3135 // The stamp travels only when it was signed *and* checked, so a
3136 // handler cannot reach for it in the case where it means nothing.
3137 let freshness = freshness_bound.then(|| message.timestamp.as_millis());
3138 self.handle_key_package_message(sender, data, signed, freshness);
3139 return Some(InternalMessageResult::Consumed);
3140 }
3141
3142 if content.starts_with(internal_prefixes::SESSION_CONFIRM_PROBE) {
3143 self.handle_session_confirm_probe(sender, content);
3144 return Some(InternalMessageResult::Consumed);
3145 }
3146
3147 if content.starts_with(internal_prefixes::SESSION_CONFIRM_ACK) {
3148 self.handle_session_confirm_ack(sender, content);
3149 return Some(InternalMessageResult::Consumed);
3150 }
3151
3152 // Handle welcome messages (session invitation)
3153 if let Some(data) = content.strip_prefix(internal_prefixes::WELCOME) {
3154 self.handle_welcome_message(sender, data);
3155 return Some(InternalMessageResult::Consumed);
3156 }
3157
3158 // Handle encrypted messages
3159 if let Some(data) = content.strip_prefix(internal_prefixes::ENCRYPTED) {
3160 if let Some(result) =
3161 self.handle_encrypted_message(sender, data, message, arrival_transport)
3162 {
3163 return Some(result);
3164 }
3165 return Some(InternalMessageResult::Consumed);
3166 }
3167
3168 // Handle connection request messages
3169 if let Some(data) = content.strip_prefix(internal_prefixes::CONN_REQUEST) {
3170 self.handle_connection_request(sender, data);
3171 return Some(InternalMessageResult::Consumed);
3172 }
3173
3174 // Handle connection accepted messages
3175 if let Some(data) = content.strip_prefix(internal_prefixes::CONN_ACCEPT) {
3176 self.handle_connection_accepted(sender, data);
3177 return Some(InternalMessageResult::Consumed);
3178 }
3179
3180 // Handle connection rejected messages
3181 if content
3182 .strip_prefix(internal_prefixes::CONN_REJECT)
3183 .is_some()
3184 {
3185 self.handle_connection_rejected(sender);
3186 return Some(InternalMessageResult::Consumed);
3187 }
3188
3189 // Handle connection cancelled messages
3190 if content
3191 .strip_prefix(internal_prefixes::CONN_CANCEL)
3192 .is_some()
3193 {
3194 self.handle_connection_cancelled(sender);
3195 return Some(InternalMessageResult::Consumed);
3196 }
3197
3198 // --- Presence, typing, and read receipt messages ---
3199
3200 if let Some(data) = content.strip_prefix(internal_prefixes::PRESENCE) {
3201 self.handle_presence_message(sender, data);
3202 return Some(InternalMessageResult::Consumed);
3203 }
3204
3205 if let Some(data) = content.strip_prefix(internal_prefixes::TYPING_INDICATOR) {
3206 self.handle_typing_indicator(sender, data);
3207 return Some(InternalMessageResult::Consumed);
3208 }
3209
3210 if let Some(data) = content.strip_prefix(internal_prefixes::READ_RECEIPT) {
3211 self.handle_read_receipt(sender, data);
3212 return Some(InternalMessageResult::Consumed);
3213 }
3214
3215 // --- Group (mesh/MLS) messages ---
3216
3217 if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_MSG) {
3218 return Some(self.handle_group_mls_msg_via(message, sender, data, arrival_transport));
3219 }
3220
3221 if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME) {
3222 let mid = message.id.as_str();
3223 self.handle_group_mls_welcome(&mid, sender, data);
3224 return Some(InternalMessageResult::Consumed);
3225 }
3226
3227 if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_COMMIT) {
3228 let mid = message.id.as_str();
3229 self.handle_group_mls_commit(&mid, sender, data);
3230 return Some(InternalMessageResult::Consumed);
3231 }
3232
3233 if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_LEAVE) {
3234 let mid = message.id.as_str();
3235 self.handle_group_mls_leave(&mid, sender, data);
3236 return Some(InternalMessageResult::Consumed);
3237 }
3238
3239 if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_ROLE_CHANGE) {
3240 let mid = message.id.as_str();
3241 self.handle_group_role_change(&mid, sender, data);
3242 return Some(InternalMessageResult::Consumed);
3243 }
3244
3245 if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_RENAME) {
3246 let mid = message.id.as_str();
3247 self.handle_group_rename(&mid, sender, data);
3248 return Some(InternalMessageResult::Consumed);
3249 }
3250
3251 // --- Group (relay) and service messages ---
3252
3253 if content.starts_with(internal_prefixes::GROUP_CREATED)
3254 || content.starts_with(internal_prefixes::GROUP_MSG)
3255 || content.starts_with(internal_prefixes::GROUP_MEMBER_ADDED)
3256 || content.starts_with(internal_prefixes::GROUP_MEMBER_REMOVED)
3257 || content.starts_with(internal_prefixes::GROUP_INFO)
3258 || content.starts_with(internal_prefixes::USER_GROUPS)
3259 || content.starts_with(internal_prefixes::GROUP_ERROR)
3260 {
3261 self.handle_group_relay_message(sender, content, arrival_transport);
3262 return Some(InternalMessageResult::Consumed);
3263 }
3264
3265 // --- Service discovery & request/response ---
3266 if content.starts_with(offline_protocol_services::SVC_MESSAGE_PREFIX) {
3267 self.handle_service_message(sender, content, message);
3268 return Some(InternalMessageResult::Consumed);
3269 }
3270
3271 None // Not an internal message
3272 }
3273
3274 /// Registers an event handler.
3275 ///
3276 /// # Arguments
3277 ///
3278 /// * `handler` - Callback function that will be called for each event
3279 pub fn on_event<F>(&mut self, handler: F)
3280 where
3281 F: Fn(Event) + Send + Sync + 'static,
3282 {
3283 let Ok(mut state) = lock_shared_state(&self.shared_state) else {
3284 error!("Failed to lock shared state in on_event");
3285 return;
3286 };
3287 state.event_handlers.push(Arc::new(handler));
3288 }
3289
3290 /// Processes pending operations (retries, timeouts, etc.).
3291 ///
3292 /// This should be called periodically to handle background tasks.
3293 pub fn process(&mut self) -> Result<()> {
3294 {
3295 let state = lock_shared_state(&self.shared_state)?;
3296 if state.state != ProtocolState::Running {
3297 return Ok(()); // Don't process if not running
3298 }
3299 }
3300
3301 // Transmit forwards whose hold has elapsed. Runs early and before the
3302 // retry ladders: other people's traffic is time-sensitive in a way our
3303 // own retries are not — a frame held too long is dropped rather than
3304 // sent late.
3305 self.flush_mesh_relays();
3306
3307 self.process_retry_queue()?;
3308 self.process_welcome_retry_queue()?;
3309 self.process_relay_register_retries();
3310 self.process_relay_broadcast_report_timeouts();
3311 self.process_timed_out_acks()?;
3312
3313 // Throttle session reconciliation to avoid expensive storage I/O
3314 // (list_sessions → Keychain/Keystore) on every tick. Only run when
3315 // there's pending work AND enough time has elapsed since the last run.
3316 self.run_throttled_reconciliation("process_tick");
3317
3318 let _ = self.prune_expired_pending_global_front(Instant::now(), 256);
3319 // Batched like the Lamport clock: the seen set reaches disk on a
3320 // change threshold or a time cadence, never per message.
3321 self.persist_dedup_seen_if_due();
3322 self.pump_media_transfers();
3323 self.refresh_nostr_key_package_slots();
3324 self.refresh_nostr_discovery_claim();
3325 // A relay is free never to send end-of-stored-events, and that is the
3326 // only natural completion signal a resolution has. Without this sweep
3327 // a quiet relay turns a lookup into a hang rather than an empty answer.
3328 self.sweep_username_resolutions();
3329 self.cleanup_expired_entries();
3330 self.evaluate_relay_role();
3331 self.tick_telemetry_categories();
3332
3333 Ok(())
3334 }
3335
3336 /// Reports this device's condition to the forwarding governor and announces
3337 /// any change in whether it is acting as a relay (`RelayPromoted`,
3338 /// `RelayDemoted`, `RelayDemotedBattery`).
3339 ///
3340 /// Runs every tick independently of telemetry: these are app-facing events
3341 /// delivered through the `EventCallback` channel, which is live whether or
3342 /// not a telemetry sink is installed. It also keeps the
3343 /// `DeviceCapabilitySnapshot.relay_role` signal honest, since
3344 /// `tick_telemetry_categories` reads the standing immediately after.
3345 ///
3346 /// **The relay standing is observed, not predicted.** It says this device
3347 /// has been carrying other people's traffic, not that its battery and
3348 /// neighbor count suggest it could — those only bias *how much* it carries
3349 /// (`set_conditions`), continuously and with no threshold to cross. So the
3350 /// promotion here is a report about the mesh, and an app can trust
3351 /// `is_relay()` to mean the device is doing the work.
3352 ///
3353 /// Two things still transition on policy rather than on observation, and
3354 /// both are gates rather than predictions: relaying switched off in
3355 /// configuration, and the battery falling through the forwarding floor.
3356 /// Each stops forwarding outright, so the standing is surrendered at once
3357 /// instead of decaying over the activity windows — the window measures
3358 /// quiet, and this is refusal.
3359 ///
3360 /// Unlike the threshold policy this replaces, a device with no battery
3361 /// reading still participates: forwarding is what is being observed, and
3362 /// it happens whether or not the host has reported a level.
3363 fn evaluate_relay_role(&mut self) {
3364 let (_statuses, available) = self.transport_manager.snapshot_status_and_available();
3365 let (battery_level, is_charging) =
3366 device_battery_from_available(self.transport_manager.current_transport(), &available);
3367
3368 let eager = matches!(self.config.relay.relay_priority, RelayPriority::Always);
3369 // The bias ramp is measured from the level at which forwarding actually
3370 // stops, which is the configured minimum raised to the hard critical
3371 // floor — not the raw configured value, which may be set below it.
3372 // Passing the unclamped one would start the ramp under the gate and
3373 // scale a device that is about to stop forwarding altogether.
3374 let forwarding_floor = self.relay_battery_floor(is_charging);
3375 self.mesh_relay
3376 .set_conditions(battery_level, is_charging, eager, forwarding_floor);
3377 let relay = &self.config.relay;
3378
3379 // Relaying switched off in configuration.
3380 if !relay.allow_relay || matches!(relay.relay_priority, RelayPriority::Never) {
3381 if self.mesh_relay.force_inactive() {
3382 self.emit_event(Event::relay_demoted("relaying disabled by configuration"));
3383 }
3384 return;
3385 }
3386
3387 // Battery through the forwarding floor. Same gate `try_relay_message`
3388 // applies to each frame, evaluated from the reading already in hand.
3389 if !self.battery_allows_relaying_with(battery_level, is_charging) {
3390 if self.mesh_relay.force_inactive() {
3391 self.emit_event(Event::relay_demoted_battery(
3392 battery_level.unwrap_or(0),
3393 self.relay_battery_floor(is_charging),
3394 ));
3395 }
3396 return;
3397 }
3398
3399 match self.mesh_relay.observe_activity(Instant::now()) {
3400 Some(RelayActivity::Began { .. }) => {
3401 let connection_count = self.known_peers.len();
3402 self.emit_event(Event::relay_promoted(connection_count, battery_level));
3403 }
3404 Some(RelayActivity::Ceased) => {
3405 self.emit_event(Event::relay_demoted(
3406 "no traffic carried for other devices recently",
3407 ));
3408 }
3409 None => {}
3410 }
3411 }
3412
3413 /// The relay standing this device currently holds, for telemetry.
3414 fn relay_role(&self) -> RelayRole {
3415 if self.mesh_relay.is_active_relay() {
3416 RelayRole::Relay
3417 } else {
3418 RelayRole::Regular
3419 }
3420 }
3421
3422 /// Per-tick telemetry work: diff transport statuses, diff device
3423 /// capability, and emit a `MetricsFrame` when `metrics_cadence` has
3424 /// elapsed. No-ops unless a sink has been installed.
3425 ///
3426 /// Runs at the end of `process()` so reliability/queue state observed
3427 /// here is the same state the rest of the tick has already advanced.
3428 /// Snapshots `get_available_transports()` exactly once per tick and
3429 /// reuses the map across every downstream helper that needs it.
3430 ///
3431 /// If an earlier step in `process()` returns `Err(...)`, this method is
3432 /// skipped and telemetry emission for that interval is deferred to the
3433 /// next successful tick — the cadence guarantee relaxes under sustained
3434 /// partial failure.
3435 ///
3436 /// # Sink-panic semantics
3437 ///
3438 /// Each per-tick cursor is advanced *before* the emit call that
3439 /// observes it, so a panicking sink does not cause re-delivery of the
3440 /// record it panicked on. The transport-status path advances
3441 /// **per-transport**: when multiple transitions fall in a single tick,
3442 /// a panic on transition K commits K's entry (at-most-once for K) but
3443 /// leaves entries L..N in `transport_status_snapshot` at their previous
3444 /// values, so the next tick re-diffs and emits them fresh. This avoids
3445 /// silent data loss for transitions that never reached the sink.
3446 ///
3447 /// The device-capability and metrics-frame paths emit at most one
3448 /// record per tick each, so a simpler "advance then emit" discipline
3449 /// suffices there: a panic advances the cursor and the same record is
3450 /// not re-emitted on the next tick.
3451 fn tick_telemetry_categories(&mut self) {
3452 let Some(ctx) = self.telemetry.clone().filter(|ctx| ctx.enabled()) else {
3453 return;
3454 };
3455 let now_ms = Utc::now().timestamp_millis();
3456 let now = Instant::now();
3457
3458 // One lock-per-transport pass: get statuses (for the transition
3459 // diff) and the available-only metrics map (for the metrics frame
3460 // and the device-capability diff). Reused across every helper
3461 // below.
3462 let (current_statuses, available) = self.transport_manager.snapshot_status_and_available();
3463
3464 // Transport-status diff: one record per changed transport. Commit
3465 // each transport's new snapshot entry *before* emitting its event
3466 // so a panic on event K is at-most-once for K; untouched entries
3467 // L..N stay at their previous values and get re-diffed next tick.
3468 let transitions =
3469 diff_transport_state(now_ms, &self.transport_status_snapshot, ¤t_statuses);
3470 for event in transitions {
3471 let transport = event.transport;
3472 match current_statuses.get(&transport) {
3473 Some(status) => {
3474 self.transport_status_snapshot.insert(transport, *status);
3475 }
3476 None => {
3477 self.transport_status_snapshot.remove(&transport);
3478 }
3479 }
3480 dispatch_record(&ctx.sink, &TelemetryRecord::TransportState(event));
3481 // An internet-capable transport coming up is the pipe's cue to
3482 // send what an offline stretch queued; the pipe decides which
3483 // transports count.
3484 #[cfg(feature = "telemetry-pipe")]
3485 if let Some(pipe) = &self.telemetry_pipe {
3486 pipe.notify_transport(event.transport, event.current);
3487 }
3488 }
3489
3490 // Device capability diff. At-most-one emission per tick, so the
3491 // simple advance-before-emit pattern preserves at-most-once.
3492 let (battery_level, is_charging) =
3493 device_battery_from_available(self.transport_manager.current_transport(), &available);
3494 let relay_role = self.relay_role();
3495 let device_now = DeviceSnap::from_parts(battery_level, is_charging, relay_role);
3496 let device_change =
3497 diff_device_capability(now_ms, self.device_capability_snapshot, device_now);
3498 self.device_capability_snapshot = Some(device_now);
3499 if let Some(snapshot) = device_change {
3500 dispatch_record(&ctx.sink, &TelemetryRecord::Device(snapshot));
3501 }
3502
3503 // Periodic metrics snapshot. Rearm cadence before emit so a
3504 // panicking sink doesn't cause an immediate retry next tick.
3505 if let Some(cadence) = ctx.config.metrics_cadence() {
3506 let due = match self.last_metrics_emit_at {
3507 None => true,
3508 Some(prev) => now.saturating_duration_since(prev) >= cadence,
3509 };
3510 if due {
3511 let is_local_relay = matches!(relay_role, RelayRole::Relay);
3512 let frame = build_metrics_frame(
3513 now_ms,
3514 self.transport_manager.current_transport(),
3515 &available,
3516 &self.retry_queue,
3517 &self.deduplicator,
3518 &self.ack_manager,
3519 self.known_peers.len(),
3520 is_local_relay,
3521 );
3522 self.last_metrics_emit_at = Some(now);
3523 dispatch_record(
3524 &ctx.sink,
3525 &TelemetryRecord::MetricsSnapshot(Box::new(frame)),
3526 );
3527 }
3528 }
3529 }
3530
3531 /// Processes messages ready for retry from the retry queue.
3532 ///
3533 /// EDGE CASE HANDLING:
3534 /// - Checks transport availability before each retry attempt
3535 /// - Handles transport switch mid-retry
3536 /// - Properly tracks retry counts and transport failures
3537 fn process_retry_queue(&mut self) -> Result<()> {
3538 // Limit batch size to prevent blocking on large queues
3539 let mut max_batch_size = crate::constants::FLUSH_BATCH_LIMIT;
3540
3541 // Opt-in resend rate cap: bound how fast the RETRY/PROBE path re-sends,
3542 // so a backlog of resends to unreachable peers cannot burst past the
3543 // relay's per-connection rate limit and flap the connection. Refills a
3544 // token bucket by elapsed wall-time and caps this call's batch to the
3545 // whole tokens available. Only resends are bounded; first-attempt sends,
3546 // ACKs, control frames and handshakes take other paths and are never
3547 // throttled, so live traffic keeps full throughput and latency. Fully
3548 // off by default (unchanged retry behaviour for every existing app).
3549 if self.config.reliability.retry.edge_driven_unreachable_dm {
3550 let now = Instant::now();
3551 match self.retry_drain_last {
3552 Some(last) => {
3553 let elapsed = now.duration_since(last).as_secs_f64();
3554 self.retry_drain_tokens = (self.retry_drain_tokens
3555 + elapsed * crate::protocol::types::RETRY_DRAIN_RATE_PER_SEC)
3556 .min(crate::protocol::types::RETRY_DRAIN_BURST);
3557 }
3558 None => {
3559 // First drain: start with a full burst budget.
3560 self.retry_drain_tokens = crate::protocol::types::RETRY_DRAIN_BURST;
3561 }
3562 }
3563 self.retry_drain_last = Some(now);
3564 let budget = self.retry_drain_tokens.floor() as usize;
3565 max_batch_size = max_batch_size.min(budget);
3566 }
3567
3568 let mut processed = 0;
3569
3570 while processed < max_batch_size {
3571 let mut entry = match self.retry_queue.dequeue_ready() {
3572 Some(e) => e,
3573 None => break,
3574 };
3575
3576 processed += 1;
3577 let previous_transport = self.transport_manager.current_transport();
3578 self.ensure_outbox_entry(&entry.message);
3579
3580 // Tier 2: re-seal against the peer's current session before this
3581 // resend, so a post-re-key epoch change no longer leaves the bytes
3582 // undecryptable. No-op for media/plaintext/unconfirmed sessions.
3583 self.reseal_for_resend_in_place(&mut entry.message);
3584
3585 let forced_transport = self.pinned_media_transport_for_message(&entry.message.id);
3586 let send_result = if let Some(transport) = forced_transport {
3587 self.transport_manager
3588 .send_via_transport(&entry.message, transport)
3589 } else {
3590 self.transport_manager.send(&entry.message)
3591 };
3592 let current_transport =
3593 forced_transport.or_else(|| self.transport_manager.current_transport());
3594
3595 match send_result {
3596 Ok(()) => {
3597 let ack_registered_now = self.ensure_ack_registration(&entry.message)?;
3598
3599 if ack_registered_now {
3600 // The resend registered a brand-new ACK (the prior one
3601 // was cleared, e.g. by an ACK timeout that re-queued
3602 // this message). A fresh ACK starts at retry_count 0, so
3603 // carry the count the retry-queue entry already holds —
3604 // advanced by this attempt — onto it. Without this the
3605 // backoff ladder resets to 0 on every relay-accepted
3606 // resend and `delay_for_retry` is pinned at its 1s floor
3607 // forever for a peer that never ACKs (an offline
3608 // recipient), flooding the relay ~once per second.
3609 self.ack_manager.set_retry_count(
3610 &entry.message.id,
3611 entry.retry_count.saturating_add(1),
3612 );
3613 } else {
3614 self.ack_manager.increment_retry_count(&entry.message.id);
3615 }
3616 self.mark_message_sent(
3617 &entry.message,
3618 current_transport,
3619 Some(entry.retry_count.saturating_add(1)),
3620 );
3621
3622 if let Some(transport) = current_transport {
3623 self.transport_manager.reset_retry_count(transport);
3624 }
3625
3626 debug!(
3627 message_id = %entry.message.id,
3628 retry_count = entry.retry_count,
3629 transport = ?current_transport,
3630 "Retry send succeeded"
3631 );
3632 }
3633 Err(e) => {
3634 // Re-enqueue with incremented retry count for backoff
3635 let next_retry_at = self
3636 .retry_queue
3637 .enqueue(entry.message.clone(), entry.retry_count + 1);
3638
3639 if let Some(transport) = forced_transport.or(previous_transport) {
3640 self.transport_manager.record_retry_failure(transport);
3641 }
3642
3643 // Surface the schedule so apps can show retry state
3644 // instead of inferring it from silence. None = the id
3645 // was already queued; the earlier emission stands.
3646 if let Some(retry_at) = next_retry_at {
3647 self.emit_event(Event::message_retrying(
3648 entry.message.id.clone(),
3649 entry.message.recipient.as_str().to_string(),
3650 entry.retry_count + 1,
3651 retry_at.timestamp_millis(),
3652 ));
3653 }
3654
3655 debug!(
3656 message_id = %entry.message.id,
3657 retry_count = entry.retry_count,
3658 transport = ?current_transport,
3659 error = %e,
3660 "Retry send failed, will retry later"
3661 );
3662 }
3663 }
3664 }
3665
3666 if processed > 0 {
3667 debug!(processed = processed, "Processed retry queue entries");
3668 // Spend the resend-rate tokens actually used (opt-in cap only).
3669 if self.config.reliability.retry.edge_driven_unreachable_dm {
3670 self.retry_drain_tokens = (self.retry_drain_tokens - processed as f64).max(0.0);
3671 }
3672 }
3673
3674 Ok(())
3675 }
3676
3677 /// Processes timed out ACKs and handles retry or failure.
3678 fn process_timed_out_acks(&mut self) -> Result<()> {
3679 let timed_out = self.ack_manager.drain_timed_out();
3680 for pending in timed_out {
3681 let message_id = pending.message_id.clone();
3682
3683 if pending.retry_count >= self.config.reliability.retry.max_retries {
3684 self.handle_max_retries_exceeded(&message_id, pending.retry_count)?;
3685 continue;
3686 }
3687
3688 self.handle_ack_timeout_retry(&message_id, pending.retry_count)?;
3689 }
3690 Ok(())
3691 }
3692
3693 /// Handles a message that has exceeded maximum retries.
3694 fn handle_max_retries_exceeded(
3695 &mut self,
3696 message_id: &MessageId,
3697 retry_count: u32,
3698 ) -> Result<()> {
3699 // A plain DM whose recipient still holds a live unreachable-park
3700 // counter re-parks instead of settling: the exhausted budget was burnt
3701 // by reachability probes — sends that succeed locally without proving
3702 // the peer is back — not by a peer believed reachable (see
3703 // try_repark_exhausted_dm).
3704 if self.try_repark_exhausted_dm(message_id) {
3705 return Ok(());
3706 }
3707
3708 // Retry exhaustion is terminal for a connection request: settle the
3709 // pending entry and surface the typed undeliverable signal alongside
3710 // the generic message_failed, so apps keep connection-request
3711 // context without parsing reason strings.
3712 let undeliverable_recipient = self.take_undeliverable_connection_request(message_id);
3713
3714 let state = lock_shared_state(&self.shared_state).map_err(|e| {
3715 error!(
3716 "Failed to lock shared state for message failed event: {}",
3717 e
3718 );
3719 e
3720 })?;
3721 state.emit_event(Event::message_failed(
3722 message_id.clone(),
3723 "Max retries exceeded",
3724 retry_count,
3725 ));
3726 if let Some(recipient) = undeliverable_recipient {
3727 warn!(
3728 recipient = %recipient,
3729 message_id = %message_id,
3730 "Connection request undeliverable: max retries exceeded"
3731 );
3732 state.emit_event(Event::connection_request_undeliverable(
3733 recipient,
3734 message_id.as_str(),
3735 "max_retries_exceeded",
3736 ));
3737 }
3738 drop(state);
3739
3740 self.handle_outbound_media_chunk_failed(message_id, "max retries exceeded");
3741 self.retry_queue.remove(&message_id.as_str());
3742 self.ack_manager.remove_ack(message_id);
3743 if let Some(entry) = self.remove_outbox_entry(message_id) {
3744 if let Some(transport) = entry.last_transport {
3745 self.transport_manager.record_delivery_failure(transport);
3746 }
3747 }
3748 Ok(())
3749 }
3750
3751 /// Handles retry logic for a timed out ACK.
3752 fn handle_ack_timeout_retry(&mut self, message_id: &MessageId, retry_count: u32) -> Result<()> {
3753 if let Some(entry) = self
3754 .outbox
3755 .get(message_id)
3756 .or_else(|| self.media_outbox.get(message_id))
3757 {
3758 let message_clone = entry.message.clone();
3759 let recipient = entry.message.recipient.as_str().to_string();
3760 let last_transport = entry.last_transport;
3761
3762 // enqueue is infallible (retry queue has no attempt limit)
3763 let next_retry_at = self.retry_queue.enqueue(message_clone, retry_count);
3764 if let Some(transport) = last_transport {
3765 self.transport_manager.record_retry_failure(transport);
3766 }
3767 if let Some(retry_at) = next_retry_at {
3768 self.emit_event(Event::message_retrying(
3769 message_id.clone(),
3770 recipient,
3771 retry_count,
3772 retry_at.timestamp_millis(),
3773 ));
3774 }
3775 } else {
3776 self.handle_missing_outbox_entry(message_id, retry_count)?;
3777 }
3778 Ok(())
3779 }
3780
3781 /// Emits a protocol event to all registered handlers.
3782 ///
3783 /// Silently no-ops if the shared state lock is poisoned.
3784 pub(crate) fn emit_event(&self, event: Event) {
3785 if let Ok(state) = lock_shared_state(&self.shared_state) {
3786 state.emit_event(event);
3787 }
3788 }
3789
3790 /// Emits a terminal settlement, or parks it until `start()` if the event
3791 /// pipeline is not live yet.
3792 ///
3793 /// Restore paths settle messages the app is still holding ids for. They run
3794 /// from `initialize_mls`, before `start()` and often before the app has
3795 /// installed its event callback, so emitting directly there would silently
3796 /// discard exactly the signals that exist to stop an id from hanging
3797 /// forever. Once running, this is a plain emit — the same call is used from
3798 /// `process()`-driven expiry, where no deferral is wanted.
3799 pub(crate) fn settle_restored_message_failure(&mut self, event: Event) {
3800 let running = self.event_pipeline_is_live();
3801 self.settle_one_restored_message_failure(event, running);
3802 }
3803
3804 /// Settles a batch, taking the shared-state lock once rather than once per
3805 /// event. Restore emits these in loops that a tampered or pre-split store
3806 /// can drive into the thousands.
3807 pub(crate) fn settle_restored_message_failures(
3808 &mut self,
3809 events: impl IntoIterator<Item = Event>,
3810 ) {
3811 let running = self.event_pipeline_is_live();
3812 for event in events {
3813 self.settle_one_restored_message_failure(event, running);
3814 }
3815 }
3816
3817 fn event_pipeline_is_live(&self) -> bool {
3818 lock_shared_state(&self.shared_state)
3819 .map(|state| state.state == ProtocolState::Running)
3820 .unwrap_or(false)
3821 }
3822
3823 fn settle_one_restored_message_failure(&mut self, event: Event, running: bool) {
3824 if running {
3825 self.emit_event(event);
3826 return;
3827 }
3828 // Every other accumulation on the restore path has an explicit ceiling
3829 // and logs what it dropped; this one is retained until `start()`, which
3830 // may never come, so it gets the same treatment. Keeping the *oldest*
3831 // is deliberate: the settlements a restore produces first are the ones
3832 // for records it examined first, and dropping those in favour of later
3833 // ones would bias the survivors by backend listing order.
3834 if self.deferred_restore_settlements.len() >= MAX_DEFERRED_RESTORE_SETTLEMENTS {
3835 self.suppressed_restore_settlements += 1;
3836 return;
3837 }
3838 self.deferred_restore_settlements.push(event);
3839 }
3840
3841 /// Returns an iterator over outbox messages (test-only).
3842 #[cfg(test)]
3843 pub(crate) fn outbox_messages(&self) -> impl Iterator<Item = &Message> {
3844 self.outbox.values().map(|e| &e.message)
3845 }
3846
3847 /// Returns the total number of entries across outbox and media_outbox (test-only).
3848 #[cfg(test)]
3849 pub(crate) fn outbox_entry_count(&self) -> usize {
3850 self.outbox.len() + self.media_outbox.len()
3851 }
3852
3853 /// Clears all outbox entries (test-only).
3854 #[cfg(test)]
3855 pub(crate) fn clear_outbox(&mut self) {
3856 self.outbox.clear();
3857 }
3858
3859 /// Returns `true` when `message_id` is anywhere in the reliability
3860 /// machinery — awaiting an ACK, held in the outbox, or queued for retry
3861 /// (test-only).
3862 ///
3863 /// Exists for the fire-and-forget frames (relay hints, delivery ACKs),
3864 /// whose correctness is defined by leaving *no* trace here: an un-ACKable
3865 /// frame that registers a pending ACK is retransmitted until the retry cap
3866 /// and then reported failed.
3867 #[cfg(test)]
3868 pub(crate) fn is_tracked_for_delivery(&self, message_id: &MessageId) -> bool {
3869 self.ack_manager.is_waiting_for_ack(message_id)
3870 || self.outbox.contains_key(message_id)
3871 || self.media_outbox.contains_key(message_id)
3872 || self.retry_queue.contains(&message_id.as_str())
3873 }
3874
3875 /// Returns a reference to the MLS manager Arc (test-only).
3876 #[cfg(test)]
3877 pub(crate) fn mls_manager_for_testing(&self) -> &Arc<RwLock<offline_protocol_mls::MlsManager>> {
3878 self.mls_manager
3879 .as_ref()
3880 .expect("MLS not initialized in test")
3881 }
3882
3883 /// Acquires a read guard on the MLS manager.
3884 ///
3885 /// Returns an error if MLS is not initialized or the lock is poisoned.
3886 pub(crate) fn read_mls_guard(
3887 &self,
3888 ) -> Result<std::sync::RwLockReadGuard<'_, offline_protocol_mls::MlsManager>> {
3889 let mls = self
3890 .mls_manager
3891 .as_ref()
3892 .ok_or_else(|| Error::Other("MLS not initialized".to_string()))?;
3893 mls.read()
3894 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))
3895 }
3896}
3897
3898#[cfg(test)]
3899pub(crate) mod tests;