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