offline_protocol/protocol/types.rs
1//! Type definitions, constants, and shared state for the protocol engine.
2
3use crate::events::{Event, EventCallback, PresenceStatus};
4use crate::telemetry::{
5 dispatch_protocol_event, dispatch_record, scrub_event, TelemetryContext, TelemetryRecord,
6};
7use crate::Error;
8use chrono::{DateTime, Utc};
9use offline_protocol_core::{
10 ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority, ReplyContext,
11};
12use offline_protocol_transport::TransportType;
13use serde::{Deserialize, Serialize};
14use std::collections::{HashSet, VecDeque};
15use std::sync::{Arc, Mutex};
16use std::time::Instant;
17use tracing::warn;
18
19/// A carrier that speaks the gateway contract, and can therefore answer about
20/// a third party's reachability.
21///
22/// Two of the five transports qualify, and the type exists so the other three
23/// cannot be passed where a gateway answer is expected. That is not a
24/// theoretical tidiness: every effect of a presence answer is carrier-scoped,
25/// so a wrong carrier records a fact against a transport that never claimed
26/// it and pins an un-park flush to a transport that never proved anything,
27/// neither of which fails loudly.
28///
29/// - **BLE and Wi-Fi Direct** are zone carriers. A neighbour being in range
30/// is a fact about this device's own link, not a claim about a third party,
31/// and it arrives through discovery rather than through a verb.
32/// - **Nostr** structurally cannot answer: a broadcast relay reports no
33/// per-recipient delivery, so it is a carrier and never a gateway. See
34/// [ADR 0017](../../../docs/adr/0017-nostr-is-a-carrier-not-a-gateway.md).
35/// This is a permanent gap, recorded here in the type so it stops being
36/// rediscovered as a bug.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum GatewayCarrier {
39 /// The internet relay, which implemented every verb before the contract
40 /// named them.
41 Internet,
42 /// A gateway daemon reached over the Reticulum transport's local IP
43 /// contract.
44 Reticulum,
45}
46
47impl GatewayCarrier {
48 /// The transport this gateway's answers are recorded against.
49 pub fn transport(self) -> TransportType {
50 match self {
51 Self::Internet => TransportType::Internet,
52 Self::Reticulum => TransportType::Reticulum,
53 }
54 }
55
56 /// How a presence answer from this gateway is labelled to the app.
57 pub fn presence_source(self) -> crate::events::PresenceSource {
58 match self {
59 Self::Internet => crate::events::PresenceSource::Internet,
60 Self::Reticulum => crate::events::PresenceSource::Reticulum,
61 }
62 }
63
64 /// The gateway behind `transport`, or `None` for a carrier that answers
65 /// no verdicts.
66 ///
67 /// Exhaustive by construction rather than by a wildcard: a sixth
68 /// transport must be classified here deliberately, and the wrong default
69 /// is the silent one. A carrier that returns `None` is not broken; it
70 /// simply never contradicts the blanket "up means reachable" assumption,
71 /// which is the residual `docs/mesh.md` records.
72 pub fn from_transport(transport: TransportType) -> Option<Self> {
73 match transport {
74 TransportType::Internet => Some(Self::Internet),
75 TransportType::Reticulum => Some(Self::Reticulum),
76 TransportType::BLE | TransportType::WiFiDirect | TransportType::Nostr => None,
77 }
78 }
79}
80
81/// Retry interval for persisting session confirmation after a transient storage error.
82pub(crate) const CONFIRMATION_RETRY_INTERVAL_SECS: i64 = 5;
83/// Probe interval for reconciling pending sessions after restart.
84pub(crate) const CONFIRMATION_PROBE_INTERVAL_SECS: i64 = 5;
85/// Minimum interval between 1:1 session re-keys for the same peer, triggered by
86/// an epoch-desync decrypt failure. A re-key is a full teardown + key-package
87/// re-exchange, so it is rate-limited well above the confirmation-probe cadence:
88/// one legit desync heals in a single round-trip, and this bounds a peer
89/// replaying stale-epoch ciphertext (or an injected wrong-epoch frame) to at
90/// most one re-key per this window rather than a storm. The floor is enforced
91/// unconditionally — a successful decrypt on the healed session does NOT reset
92/// it — so an attacker cannot defeat it by interleaving replays with legit
93/// traffic (see `schedule_session_rekey`).
94pub(crate) const REKEY_INTERVAL_SECS: i64 = 30;
95/// Number of welcome retry records processed per tick.
96pub(crate) const WELCOME_RETRY_BATCH_SIZE: usize = 20;
97/// Hard TTL for outbound welcome lifecycle records.
98pub(crate) const WELCOME_LIFECYCLE_TTL_SECS: i64 = 300;
99/// Jitter ratio applied to welcome retry backoff delays.
100pub(crate) const WELCOME_RETRY_JITTER_RATIO: f64 = 0.2;
101/// Timeout waiting for explicit internet send confirmation for welcome.
102pub(crate) const WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS: i64 = 10;
103/// Timeout waiting for a mesh (BLE / WiFi-Direct) welcome to be confirmed by
104/// the peer proving the session (probe / ack / welcome / decrypt). A mesh
105/// `send()` returning Ok only means the local stack accepted the bytes, not
106/// that the multi-fragment Welcome reassembled on the peer — so the lifecycle
107/// stays non-terminal and the retry queue re-sends the whole Welcome after this
108/// window, recovering from a lost fragment. Slightly longer than the internet
109/// timeout to allow a slow multi-fragment Welcome to assemble plus the probe
110/// round-trip before paying to re-fragment and re-send it.
111pub(crate) const WELCOME_MESH_CONFIRM_TIMEOUT_SECS: i64 = 15;
112/// Retry cadence for a Welcome that has no transport carrier at all (the peer is
113/// simply unreachable right now). A send is guaranteed to fail with no carrier,
114/// so we do NOT burn the speculative send/transition/event churn on it every
115/// retry tick — we park it and re-check on this slow interval instead. The
116/// primary recovery is event-driven (`on_neighbor_discovered` → re-arm fires the
117/// instant a carrier surfaces the peer); this poll is only a safety net for a
118/// carrier returning without a fresh discovery event, so it is deliberately far
119/// slower than the data-plane retry interval to keep an offline device quiet.
120/// Also the base interval for the plain-DM unreachable reachability probe
121/// (`handle_recipient_unreachable_for_message`), which shares this
122/// escalation.
123pub(crate) const WELCOME_NO_CARRIER_RETRY_SECS: i64 = 15;
124/// Cap for the escalating retry interval of a welcome repeatedly parked
125/// `PeerUnreachable`, on every carrier (see
126/// `apply_recipient_unreachable_failure`). Each consecutive unreachable park
127/// doubles the interval from [`WELCOME_NO_CARRIER_RETRY_SECS`] up to this
128/// cap: DORS may keep selecting the internet path for the timed retry, and
129/// every such round trips another relay `DeliveryError` with the attempt
130/// refunded — without escalation that is an unbounded 15s resend loop into
131/// the relay for as long as the peer stays offline. At the cap the steady
132/// state matches the presence-rescue cadence (one send per 10 min), which is
133/// cheap and self-resolving. Shared by the plain-DM unreachable probe
134/// (`handle_recipient_unreachable_for_message`, per-peer
135/// `dm_unreachable_parks` counter) for the same reason — and there the
136/// escalation carries the whole bound, since that probe runs on every carrier
137/// (see `park_unreachable_dm`), including internet-only devices.
138pub(crate) const WELCOME_UNREACHABLE_RETRY_CAP_SECS: i64 = 600;
139/// Only used when `RetryConfig::edge_driven_unreachable_dm` is opted in. How
140/// many escalating timed probes a plain DM to an unreachable peer gets before
141/// it goes edge-driven (stops being timed-probed and rests in the outbox until
142/// the peer next proves reachable). One probe (plus the original send) gives a
143/// brief blip tolerance; beyond that, recovery is the reachability edge — which
144/// in an opted-in deployment is reliable (peers interact / advertise presence
145/// on return), so more blind probes only lengthen the transient after a mass
146/// disconnect. Default-off deployments never consult this (they probe forever
147/// at the documented 15s->600s cap).
148pub(crate) const DM_UNREACHABLE_PROBE_LIMIT: u32 = 1;
149/// Only used when `RetryConfig::edge_driven_unreachable_dm` is opted in. On the
150/// startup/reconnect re-drive, a still-undelivered outbox message first sent
151/// longer ago than this is left edge-driven rather than re-driven, so a restart
152/// does not re-ramp a durably-failing backlog. Default-off deployments never
153/// consult this.
154pub(crate) const OUTBOX_DURABLE_UNREACHABLE_AGE_SECS: i64 = 300;
155/// Only used when `RetryConfig::edge_driven_unreachable_dm` is opted in.
156/// Sustained rate (per second) and burst budget for the RETRY/PROBE resend path
157/// (`process_retry_queue`). Set below the reference relay's per-connection limit
158/// (10/s sustained, 30 burst) so resends to a fleet of unreachable peers can
159/// never burst the connection into a rate-limit disconnect. Only resends are
160/// bounded; first-attempt sends, ACKs, control frames and handshakes are never
161/// throttled.
162pub(crate) const RETRY_DRAIN_RATE_PER_SEC: f64 = 8.0;
163pub(crate) const RETRY_DRAIN_BURST: f64 = 24.0;
164/// Age limit for a welcome lifecycle to keep its peer on the presence
165/// watchlist (`welcome_pending_peers`). Without it the watch set only ever
166/// grows: every offline presence answer re-parks the record and pushes its
167/// `expires_at`, so a permanently-dead peer (abandoned install) is watched —
168/// and its parked lifecycle persisted — forever, and each such peer occupies
169/// rotation slots that delay presence rescue for live peers. Once unwatched,
170/// offline answers stop, `expires_at` stops being pushed, and the record
171/// ages out through normal expiry; recovery degrades to peer-initiated
172/// contact or mesh discovery, both of which rebuild the lifecycle.
173pub(crate) const WELCOME_WATCHLIST_MAX_AGE_SECS: i64 = 14 * 24 * 60 * 60;
174/// Backoff base for presence-driven welcome rescue. The first rescue for a
175/// peer is immediate; each subsequent rescue that still fails to prove the
176/// session doubles the wait (40s, 80s, 160s, ...) up to
177/// [`WELCOME_PRESENCE_RESCUE_MAX_SECS`]. Bounds the resend loop when a peer
178/// is provably online but can never confirm (stale key package after a
179/// reinstall, incompatible peer version) — without it the platform's 20s
180/// presence watch would re-send the multi-frame MLS welcome forever.
181pub(crate) const WELCOME_PRESENCE_RESCUE_BASE_SECS: i64 = 40;
182/// Cap for the presence-rescue backoff (10 minutes). Deliberately not a
183/// terminal state: a peer that stays online but never confirms keeps getting
184/// one rescue per cap interval, forever. That steady state is cheap (one
185/// multi-frame welcome per 10 min per such peer) and self-resolving — the
186/// lifecycle disappears the moment the session confirms — so a give-up
187/// threshold would only add a way to strand a recoverable session.
188pub(crate) const WELCOME_PRESENCE_RESCUE_MAX_SECS: i64 = 600;
189/// Well-known prefix for transport send-failure reasons meaning "the carrier
190/// is up but this recipient is unreachable on it" (e.g. the internet relay
191/// answered `DeliveryError` for an offline peer). Classified in
192/// `on_transport_send_failed` as authoritative proof the frame was dropped:
193/// instead of burning a retry attempt, a welcome refunds it and parks on an
194/// escalating reachability probe (`apply_recipient_unreachable_failure`), and
195/// a plain DM parks the same way (`park_unreachable_dm`). The escalation, not
196/// a carrier guard, is what keeps those probes from becoming a resend loop
197/// into the relay.
198///
199/// Cross-layer contract: the React Native platform bridges
200/// (`InternetManager.kt` / `InternetManager.swift`) hardcode this literal when
201/// calling `internet_send_failed_with_reason` — keep them in sync.
202pub(crate) const SEND_FAIL_REASON_RECIPIENT_UNREACHABLE: &str = "recipient_unreachable";
203
204/// The relay accepted a frame for a recipient with no live socket and handed
205/// it to a push notification (`MessageSent { pushed: true }`). Not a failure:
206/// the push may deliver it. What it does establish is that the recipient is
207/// not on the relay, and that the push is the only thing carrying this frame:
208/// a relay whose mailbox also holds a copy says so with `stored: true`, which
209/// the bridges report as [`SEND_FAIL_REASON_RELAY_PUSHED_STORED`] instead.
210///
211/// Deliberately its own token rather than a `recipient_unreachable` tail.
212/// That prefix fast-fails connection requests and moves Welcomes to `Failed`,
213/// both wrong for a frame that may have arrived. This token parks plain DMs
214/// only (`park_relay_pushed_dm`), and connection requests and Welcomes stay
215/// on their ordinary path.
216///
217/// Cross-layer contract: the React Native platform bridges
218/// (`InternetManager.kt` / `InternetManager.swift`) and the Python relay
219/// client (`internet_manager.py`) pass this exact literal to
220/// `internet_send_failed_with_reason`; pinned on the React Native side by
221/// `react_native_relay_parks_a_pushed_message_sent` and on the Python side by
222/// `TestMessageSentPushed`. That call must not also score the report as a send
223/// failure; see `OfflineProtocol::send_report_is_carrier_failure`.
224pub(crate) const SEND_FAIL_REASON_RELAY_PUSHED: &str = "relay_pushed";
225
226/// The relay's mailbox holds this frame for the recipient's next connection
227/// (`DeliveryError { stored: true }`, on a relay advertising `mailbox_v1`).
228///
229/// The socket write still missed, so this is the `recipient_unreachable`
230/// verdict in every respect but one: the frame is not lost, and the relay
231/// re-sends it the moment the recipient connects. A plain DM is therefore
232/// parked *edge-driven* — no escalating reachability probe — because a probe
233/// would only rewrite the copy the relay already holds. That is what the
234/// relay's own contract asks for ("do not retry faster on its account"), and
235/// what keeps a fleet whose peers are merely asleep from paying relay traffic
236/// for a redelivery the relay is already committed to. Recovery without the
237/// probe is the reachability edge, and the relay's drain either way.
238///
239/// Everything that is not a parkable plain DM is deliberately unchanged: the
240/// token is normalized back to [`SEND_FAIL_REASON_RECIPIENT_UNREACHABLE`] in
241/// `on_transport_send_failed_via`, so connection requests still fast-fail and
242/// Welcomes still park on their own lifecycle. Both carry their own proof of
243/// arrival, and a held copy is not a reason to stop waiting for it.
244///
245/// Cross-layer contract: the React Native platform bridges
246/// (`InternetManager.kt` / `InternetManager.swift`) and the Python relay
247/// client (`internet_manager.py`) pass this exact literal, and only for the
248/// one id the relay named — `stored` is a statement about a single message,
249/// never about the recipient's other in-flight frames. Pinned on the React
250/// Native side by `react_native_relay_parks_a_stored_delivery_error`.
251pub(crate) const SEND_FAIL_REASON_RELAY_STORED: &str = "relay_stored";
252
253/// [`SEND_FAIL_REASON_RELAY_PUSHED`] for a frame the mailbox also holds
254/// (`MessageSent { pushed: true, stored: true }`).
255///
256/// Parks exactly as `relay_pushed` does — plain DMs only, no
257/// `MessageUndeliverable`, kept out of the carrier's failure accounting —
258/// except that the park schedules no reachability probe, for the reason
259/// [`SEND_FAIL_REASON_RELAY_STORED`] gives.
260///
261/// Two tokens rather than one token and a flag because the two origins differ
262/// for everything that is *not* a plain DM: a pushed frame must not fast-fail
263/// a connection request, and a missed one must. Collapsing them would trade
264/// that distinction for one fewer string.
265pub(crate) const SEND_FAIL_REASON_RELAY_PUSHED_STORED: &str = "relay_pushed_stored";
266
267/// Fallback token for a send failure that classifies as nothing more specific.
268pub(crate) const SEND_FAIL_REASON_TRANSPORT: &str = "transport_send_failed";
269/// A Welcome was written to a carrier that never confirmed it.
270pub(crate) const SEND_FAIL_REASON_CONFIRM_TIMEOUT: &str = "send_confirmation_timed_out";
271
272/// Every token [`classify_transport_send_error`] and
273/// [`send_failure_token`] can produce.
274///
275/// Membership is what makes classification idempotent: a value already drawn
276/// from this list re-classifies to itself, so a record written by one producer
277/// and re-read by the other is not degraded to the fallback. Keep it in sync
278/// with the two functions — the round-trip test
279/// `every_send_failure_token_classifies_to_itself` fails otherwise.
280pub(crate) const SEND_FAIL_REASON_TOKENS: &[&str] = &[
281 SEND_FAIL_REASON_RECIPIENT_UNREACHABLE,
282 SEND_FAIL_REASON_RELAY_PUSHED,
283 SEND_FAIL_REASON_RELAY_STORED,
284 SEND_FAIL_REASON_RELAY_PUSHED_STORED,
285 SEND_FAIL_REASON_TRANSPORT,
286 SEND_FAIL_REASON_CONFIRM_TIMEOUT,
287 "transport_not_connected",
288 "peer_not_reachable",
289 "message_too_large",
290 "serialization_failed",
291 "crypto_failed",
292 "session_not_ready",
293 "recipient_blocked",
294 "media_transfer_limit",
295 "group_not_found",
296 "invalid_request",
297 "protocol_state_invalid",
298];
299
300/// Maps a platform-supplied or previously-stored send-failure string onto a
301/// fixed local vocabulary.
302///
303/// # Why the string cannot be passed through
304///
305/// The bridges build this string from the relay's `DeliveryError.reason`
306/// (`"recipient_unreachable: <relay text>"` — the shape is a cross-layer
307/// contract, see [`SEND_FAIL_REASON_RECIPIENT_UNREACHABLE`]), and the Nostr
308/// bridge from a relay's `OK` rejection text. Both tails are remote-chosen,
309/// unbounded, and not ours. They reach `MessageUndeliverable.reason`,
310/// `ConnectionRequestUndeliverable.reason` and — through
311/// `WelcomeLifecycleRecord::last_transport_error` —
312/// `WelcomeSendFailed.transport_error`, every one of which the telemetry
313/// scrubber ships verbatim beside a `recipient`/`peer_id` it *hashes*. An
314/// identifier in the tail therefore undoes the hashing in the same record, and
315/// even the honest relay writes one: its own wording renders the peer it
316/// concerns.
317///
318/// # Why the prefix survives
319///
320/// The `recipient_unreachable` token is not decoration — core prefix-matches it
321/// to fast-fail connection requests and to park plain DMs, and the bridges
322/// hardcode it. So the classification *is* the bare token: everything the
323/// protocol reads is kept, and only the prose tail is dropped.
324///
325/// Matching is prefix-based for that one token (the bridges append to it) and
326/// exact otherwise, with a closed fallback — a bridge or relay rewording
327/// degrades to [`SEND_FAIL_REASON_TRANSPORT`], never back to shipping its text.
328/// The `&'static str` return is load-bearing the same way
329/// [`GroupErrorPayload::classify_reason`] and `MlsError::privacy_safe_reason`
330/// are: it makes interpolating the input unrepresentable rather than merely
331/// discouraged.
332///
333/// Idempotent by construction, which matters twice: a record persisted before
334/// this existed is classified when it is *read* (the stored string may be raw
335/// relay prose), and a record written after it is classified again on the way
336/// out without changing.
337pub(crate) fn classify_transport_send_error(raw: &str) -> &'static str {
338 // Prefix, not equality: this is the one token the bridges extend.
339 if raw.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE) {
340 return SEND_FAIL_REASON_RECIPIENT_UNREACHABLE;
341 }
342 // Already classified: a re-read of a record either producer wrote. Returns
343 // the entry from the table rather than `raw`, which is what keeps the
344 // signature `&'static str`.
345 if let Some(token) = SEND_FAIL_REASON_TOKENS.iter().find(|t| **t == raw) {
346 return token;
347 }
348 match raw {
349 // The fixed literals the UniFFI layer substitutes when a bridge reports
350 // a failure without a reason of its own. Locally minted, but classified
351 // here too so the vocabulary has exactly one source.
352 "Internet transport send failed"
353 | "Reticulum transport send failed"
354 | "Nostr transport send failed" => SEND_FAIL_REASON_TRANSPORT,
355 "Welcome send confirmation timed out" => SEND_FAIL_REASON_CONFIRM_TIMEOUT,
356 // Everything else, including anything a relay or bridge chose.
357 _ => SEND_FAIL_REASON_TRANSPORT,
358 }
359}
360
361/// Classifies a locally raised send failure for an event field.
362///
363/// The sibling of [`classify_transport_send_error`] for errors this device
364/// produced rather than received. It exists for the same reason: the transport
365/// layer renders the counterparty into its own message —
366/// `PeerNotReachable("no mesh transport holds a link to {peer_id}…")`, and the
367/// same in the BLE and Wi-Fi Direct backends — so `format!("{}", err)` on a
368/// routine send failure ships the peer's address to a telemetry sink. On
369/// `MessageDeferred` that was the *only* identity in the record, since the
370/// event carried no field for the scrubber to hash.
371///
372/// The match over [`crate::Error`] is exhaustive with no `_` arm: it lives in
373/// the defining crate, so a new variant fails to compile here and forces the
374/// privacy decision where the variant is written. The transport arm delegates
375/// to `offline_protocol_transport::Error::code`, which is that crate's own
376/// exhaustive, documented-stable classifier — its doc names telemetry
377/// classification as the intended use. Matching its output needs a fallback
378/// arm (a foreign `#[non_exhaustive]` enum can grow a variant), and that
379/// fallback is closed: a new transport variant degrades to
380/// [`SEND_FAIL_REASON_TRANSPORT`], never to its rendered text.
381pub(crate) fn send_failure_token(err: &crate::Error) -> &'static str {
382 use crate::Error as E;
383 match err {
384 E::Transport(inner) => match inner.code() {
385 "TRANSPORT_NOT_AVAILABLE" => "transport_not_connected",
386 "PEER_NOT_REACHABLE" => "peer_not_reachable",
387 "MESSAGE_TOO_LARGE" => "message_too_large",
388 "SERIALIZATION_ERROR" => "serialization_failed",
389 "CRYPTO_ERROR" => "crypto_failed",
390 _ => SEND_FAIL_REASON_TRANSPORT,
391 },
392 E::NotStarted | E::AlreadyStarted | E::InvalidState(_) => "protocol_state_invalid",
393 E::InvalidConfiguration(_)
394 | E::InvalidArgument(_)
395 | E::PermissionDenied(_)
396 | E::TelemetryConfigInvalid(_) => "invalid_request",
397 E::Core(_) | E::Serialization(_) => "serialization_failed",
398 E::Router(_) | E::Reliability(_) | E::Service(_) => SEND_FAIL_REASON_TRANSPORT,
399 E::Mls(_) | E::EncryptFailed(_) => "crypto_failed",
400 E::MlsNotInitialized | E::SessionNotReady(_) | E::NoKeyPackage(_) => "session_not_ready",
401 E::UserBlocked(_) => "recipient_blocked",
402 E::MediaTransferLimit(_) => "media_transfer_limit",
403 E::GroupNotFound(_) => "group_not_found",
404 // Data-layer failures cannot reach a send path today: nothing in the
405 // data layer sends. They are classified anyway because this match is
406 // where the privacy decision is forced, and "unreachable" is a claim
407 // that stops being true the moment replication lands. Each token is
408 // locally minted and carries no identifier: the errors themselves
409 // interpolate only byte counts and a decode detail, never a space,
410 // document or peer.
411 E::DataDisabled | E::DataStorageUnavailable => "data_unavailable",
412 E::DocTooLarge { .. } => "data_doc_too_large",
413 E::DataCorrupted(_) => "data_corrupt",
414 E::Other(_) => SEND_FAIL_REASON_TRANSPORT,
415 }
416}
417/// Minimum interval between session reconciliation scans (list_sessions I/O).
418/// Keeps the expensive Keychain/Keystore I/O out of the hot path so that
419/// sendMessage() is not blocked by Mutex contention on every process tick.
420pub(crate) const RECONCILIATION_THROTTLE_MS: u64 = 2_000;
421/// Lamport clock ticks between storage persistence writes. Avoids a
422/// Keychain/Keystore write on every sent and received message. On crash
423/// recovery, at most this many ticks are lost, which is safe — the clock
424/// is only used for causal ordering and the gap is absorbed on the next
425/// merge with any peer.
426pub(crate) const LAMPORT_PERSIST_INTERVAL: u64 = 64;
427
428/// Most seen ids one `DedupSeenRecord` carries. Matches the default
429/// `max_tracked_messages`; a larger configured tracker persists its newest
430/// ids only, which are the ones a replay is most likely to repeat.
431pub(crate) const MAX_PERSISTED_DEDUP_IDS: usize = 2000;
432
433/// Seen-set changes that force a write before the time cadence elapses. A
434/// burst of inbound traffic is exactly when the set is worth having on disk,
435/// and 32 changes at ~60 bytes each is well under the cost of one write.
436pub(crate) const DEDUP_PERSIST_DIRTY_THRESHOLD: u32 = 32;
437
438/// How long a dirty seen set may wait for the next `process()` tick before it
439/// is written. Bounds what a crash loses to a few seconds of receipts.
440pub(crate) const DEDUP_PERSIST_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
441
442/// The deduplicator's seen set as persisted under
443/// [`storage_keys::DEDUP_SEEN_IDS`].
444///
445/// The deduplicator was in-memory only, so after a restart the socket copy of
446/// a message the app had already consumed from a push injection was not
447/// recognised as a duplicate: it went to the ratchet, whose generation for
448/// that message was already spent, and surfaced as a decryption failure — a
449/// spurious signal the app counts toward its split-brain breaker, which then
450/// tears down a healthy session. Persisting the ids makes the second copy
451/// recognisable across the restart. Ids and timestamps only; nothing here is
452/// content.
453///
454/// `version` is the forward-compatibility hinge: an unknown version is dropped
455/// rather than guessed at, and the set simply starts empty.
456#[derive(Serialize, Deserialize)]
457pub(crate) struct DedupSeenRecord {
458 #[serde(default = "dedup_seen_record_version")]
459 pub(crate) version: u8,
460 pub(crate) entries: Vec<offline_protocol_reliability::SeenId>,
461}
462
463/// The only record version this build writes or reads.
464pub(crate) const DEDUP_SEEN_RECORD_VERSION: u8 = 1;
465
466fn dedup_seen_record_version() -> u8 {
467 DEDUP_SEEN_RECORD_VERSION
468}
469/// Seconds the Nostr receive watermark must advance before it is written back
470/// to protocol-state storage. Same debounce role as
471/// [`LAMPORT_PERSIST_INTERVAL`]: every inbound relay event moves the mark, and
472/// a storage write per message is exactly the I/O the Lamport debounce exists
473/// to avoid.
474///
475/// What an un-flushed gap costs is bounded and one-directional: a launch that
476/// loses up to this much advance re-fetches a slightly wider slice of relay
477/// history, which dedup absorbs. It can never *skip* messages, because the
478/// stale mark is always behind the live one.
479pub(crate) const NOSTR_WATERMARK_PERSIST_INTERVAL_SECS: i64 = 300;
480pub(crate) const MEDIA_TRANSFER_STALE_TIMEOUT_SECS: u64 = 300;
481/// Maximum number of tracked known peers for service discovery.
482pub(crate) const MAX_KNOWN_PEERS: usize = 1000;
483/// How long a known peer stays tracked without being re-seen.
484///
485/// Peers on carriers with no disconnect signal (Internet, Reticulum, Nostr,
486/// and WiFi Direct message-path senders) are only ever *added* to
487/// `known_peers`; this TTL is their eviction path, swept from the periodic
488/// `cleanup_expired_entries` tick. A second layer — least-recently-seen
489/// eviction when an insert hits `MAX_KNOWN_PEERS` — guarantees a newly
490/// discovered peer is always tracked even between sweeps.
491///
492/// Deliberately generous: a connected-but-quiet BLE peer refreshes only via
493/// platform advertisement sightings (BLE inbound messages do not route
494/// through the discovery hook), so a short TTL would evict it while still
495/// connected. Eviction is self-healing (the peer is re-tracked on its next
496/// advertisement or message), so erring long only delays hygiene.
497pub(crate) const KNOWN_PEER_TTL_SECS: u64 = 1800;
498
499/// Battery level below which a device stops carrying traffic for other
500/// people even while charging.
501///
502/// Re-exported from the router crate rather than restated here: message
503/// forwarding and the relay role apply the same floor, and a device must not
504/// keep carrying traffic at a level that would have stripped it of the role.
505/// Two copies of the number would eventually disagree, and nothing would
506/// notice.
507pub(crate) use offline_protocol_router::CRITICAL_RELAY_BATTERY_LEVEL;
508
509/// The metadata keys a signed control frame carries: the Ed25519 signature
510/// over the canonical payload, and the public key it verifies against, both
511/// base64.
512///
513/// Re-exported rather than restated because the payload they accompany is
514/// built by `offline_protocol_sealed::control_signing_payload`, which a leaf
515/// node calls too. `CTRL_SIGN_DOMAIN` lives there as well and is no longer
516/// named in this crate outside its tests: nothing here builds the payload any
517/// more. A key or a domain declared in two places is one that can differ in
518/// one of them, and the only symptom is a signature that stops verifying.
519pub(crate) use offline_protocol_sealed::{CTRL_PK_META_KEY, CTRL_SIG_META_KEY};
520
521/// Maximum number of peers retained in the encryption-capability set that gates
522/// inbound plaintext.
523///
524/// Sized to hold every legitimate source at once — the durable capability
525/// records plus the MLS session list — with room to spare, so a real deployment
526/// never reaches it. The cap exists because the set is keyed by a wire-claimed
527/// sender id and nothing else bounds it.
528///
529/// Note what does *not* bound it: requiring a signature. The control gate does
530/// now reject unsigned control frames before dispatch, so every marking path
531/// runs on a verified frame — and that changes nothing here. A signature proves
532/// the signer owns the address it claims; it does not prove the address belongs
533/// to anyone real, and minting one costs a keygen. An attacker spending keygens
534/// produces unlimited distinct well-signed peers, so "written only after a
535/// signature verifies" is not by itself a bound on anything.
536///
537/// It bounds the durable `encryption_capable_peers` storage category as well,
538/// because `OfflineProtocol::record_encryption_capable` persists only for a
539/// peer this cap admitted.
540///
541/// Unlike the other maps keyed by a wire-claimed id, this one **refuses** at
542/// capacity instead of resetting or evicting: forgetting a peer here is the
543/// fail-open direction. See `OfflineProtocol::mark_encryption_capable`.
544pub(crate) const MAX_ENCRYPTION_CAPABLE_PEERS: usize = 8192;
545
546/// Maximum number of blocked users to retain.
547pub(crate) const MAX_BLOCKED_USERS: usize = 10_000;
548
549/// Maximum number of peers tracked for once-per-peer
550/// `PlaintextReceiveRejected` warning suppression.
551///
552/// The keys are wire-claimed (attacker-controllable) sender ids, so the set
553/// resets at capacity instead of growing without bound: a flood of forged
554/// senders degrades the throttle to once-per-peer-per-generation while
555/// memory stays capped.
556pub(crate) const MAX_PLAINTEXT_RECEIVE_WARNED_PEERS: usize = 1000;
557
558/// Maximum number of peers tracked for once-per-peer-per-code control-gate
559/// warning suppression.
560///
561/// Every rejection the control gate reports is reachable from an unauthenticated
562/// frame carrying an attacker-chosen sender id — that is what a gate rejection
563/// *is* — so without a throttle an off-path injector turns the app's event
564/// stream into a flood and desensitizes operators to the one code that has no
565/// benign reading (`SENDER_ADDRESS_MISMATCH`). Bounded and reset at capacity for
566/// the same reason as [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`]: the keys are
567/// attacker-controlled, so a forged-sender flood degrades the throttle to
568/// once-per-peer-per-generation rather than growing memory without bound.
569pub(crate) const MAX_CONTROL_GATE_WARNED_PEERS: usize = 1000;
570
571/// Maximum number of pending (received-but-unused) peer key packages retained
572/// in memory and in durable `MlsStorage`.
573///
574/// Keyed by the wire-claimed `sender`, so — like [`MAX_KNOWN_PEERS`] — an
575/// unpinned peer can flood distinct forged ids under the default config. Each
576/// entry is also written to durable secure storage (`persist_peer_key_package`,
577/// iOS Keychain / Android Keystore) and re-loaded on boot, so without this cap
578/// a flood grows durable storage without bound and re-inflates memory on every
579/// restart. At capacity a new peer evicts the soonest-to-expire entry and drops
580/// its persisted copy; the restore-on-boot loop is capped the same way.
581pub(crate) const MAX_PENDING_KEY_PACKAGES: usize = 1000;
582
583/// Maximum outbound messages waiting for one peer's MLS session.
584///
585/// This queue is durable and accepts application-controlled content, so a peer
586/// that never completes session establishment must not grow it without bound.
587/// The limit matches the default inbound pending-decryption per-peer bound.
588///
589/// A count alone does not bound memory or durable storage — message content is
590/// application-supplied — so this cap works together with
591/// [`MAX_PENDING_MESSAGE_BYTES_PER_PEER`]; whichever binds first wins.
592pub(crate) const MAX_PENDING_MESSAGES_PER_PEER: usize = 64;
593
594/// Maximum outbound messages waiting for MLS sessions across all peers.
595///
596/// At capacity the globally oldest message is settled as failed before the new
597/// message is admitted. The limit matches the default inbound pending queue.
598/// Paired with [`MAX_PENDING_MESSAGE_BYTES_GLOBAL`], as above.
599pub(crate) const MAX_PENDING_MESSAGES_GLOBAL: usize = 4096;
600
601/// Maximum pending entries expired in one `process()` tick.
602///
603/// Each expiry is a settlement *and* a durable delete, and the two have to stay
604/// paired — so what gets bounded is how many entries expire per pass, not how
605/// many deletes it may issue. Messages queued in a burst share a `queued_at` and
606/// so come due in a burst; without this, one tick of the 100 ms bindings loop
607/// could issue up to [`MAX_PENDING_MESSAGES_GLOBAL`] synchronous deletes, each a
608/// device barrier on every built-in provider.
609///
610/// The remainder is not deferred indefinitely: the entries left behind are still
611/// past their deadline, so `recompute_next_pending_message_expiry` leaves
612/// `next_pending_message_expiry` in the past and the very next tick drains
613/// another pass.
614pub(crate) const MAX_PENDING_EXPIRIES_PER_PASS: usize = 64;
615
616/// Maximum per-message records one launch writes migrating legacy queues.
617///
618/// The migration's writes are the one part of the pending restore that is not
619/// already bounded by a delete budget, and a `store` is the more expensive of
620/// the two: every built-in provider flushes the record *and* its directory. A
621/// pre-split install sitting near [`MAX_PENDING_MESSAGES_GLOBAL`] would
622/// otherwise pay thousands of device barriers inside `initialize_mls`, on the
623/// launch path, where a mobile watchdog is watching.
624///
625/// Sized to match [`crate::protocol::storage::MAX_RESTORE_PRUNE_DELETES`], so
626/// the whole walk's barrier count stays the same order of magnitude whichever
627/// half of it does the work. Checked per recipient and before its first write,
628/// so a queue is either migrated whole or left entirely on disk for the next
629/// launch — never split across the two layouts with nothing to reconcile it.
630pub(crate) const MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH: usize = 512;
631
632/// Maximum terminal settlements parked by restore for `start()` to drain.
633///
634/// The restore caps already bound how many can be produced, but they bound it
635/// as a sum across every category, and nothing drains this queue until
636/// `start()` — which an application that only calls `initialize_mls`, or that
637/// retries it against a store that keeps failing, may never reach. Sized above
638/// the largest single-category restore ([`MAX_PENDING_MESSAGES_GLOBAL`], the
639/// pending queue's own global cap) so no legitimate restore is ever truncated;
640/// past that point the count is reported instead of the events.
641pub(crate) const MAX_DEFERRED_RESTORE_SETTLEMENTS: usize = 2 * MAX_PENDING_MESSAGES_GLOBAL;
642
643/// Maximum size of application-supplied message content accepted at the public
644/// send boundary, in bytes.
645///
646/// Enforced at the boundary — not at transmit time — because a message queued
647/// behind MLS session establishment never reaches the transport's
648/// `DEFAULT_MAX_MESSAGE_SIZE` (1 MiB) check: it sits in the durable pending
649/// queue first. Without a boundary cap a handful of very large sends could
650/// exhaust mobile memory and protocol-state disk while still being formally
651/// "within" the count caps above.
652///
653/// 256 KiB leaves ample headroom under the 1 MiB transport ceiling for MLS
654/// ciphertext expansion, base64, and the JSON wire envelope, so anything this
655/// path accepts can actually be delivered. Larger payloads belong on the media
656/// path (`send_media`), which chunks.
657pub(crate) const MAX_MESSAGE_CONTENT_BYTES: usize = 256 * 1024;
658
659/// Maximum total serialized bytes of one peer's pending-session queue.
660///
661/// At capacity the peer's oldest entries are settled as failed until the
662/// incoming message fits, exactly like the count cap. Deliberately larger than
663/// [`MAX_MESSAGE_CONTENT_BYTES`] plus [`MAX_RICH_EXTRAS_BYTES`] so a single
664/// boundary-legal message always fits in an empty queue and admission can never
665/// livelock (pinned by `pending_queue_byte_budgets_admit_any_boundary_legal_message`).
666///
667/// # Durable cost
668///
669/// Pending messages are persisted one record per message
670/// ([`storage_keys::PENDING_MESSAGE_ENTRIES`]), so an enqueue writes its own
671/// entry and nothing else: filling one peer to this budget costs `budget` bytes
672/// written, not `budget × entries / 2`. The per-recipient layout this replaced
673/// re-serialized the peer's whole queue on every enqueue, which made the byte
674/// cost quadratic in the entry count.
675///
676/// Bytes were never the expensive half, though, and sizing this budget off them
677/// would be reading the wrong meter. Every built-in provider pays two device
678/// barriers per `store` (the record's own flush plus its directory's) and one
679/// per `delete`, *regardless of record size* — so a 500-byte write and a 2 MiB
680/// write cost the same in the resource that actually dominates. What the
681/// per-message layout buys is not primarily speed but that a record which will
682/// not open still names the message it lost; see
683/// [`storage_keys::PENDING_MESSAGE_ENTRIES`].
684///
685/// So this budget is still sized to bound a pathological queue rather than to
686/// describe a normal one — real queues hold a handful of short messages waiting
687/// on a handshake. Lowering it below `MAX_MESSAGE_CONTENT_BYTES +
688/// MAX_RICH_EXTRAS_BYTES` breaks admission outright.
689pub(crate) const MAX_PENDING_MESSAGE_BYTES_PER_PEER: usize = 2 * 1024 * 1024;
690
691/// Maximum total serialized bytes of the pending-session queue across all
692/// peers. Bounds the whole feature's footprint on a mobile device regardless of
693/// how many peers are mid-establishment.
694pub(crate) const MAX_PENDING_MESSAGE_BYTES_GLOBAL: usize = 16 * 1024 * 1024;
695
696/// Maximum size of a single persisted protocol-state record, in bytes.
697///
698/// Enforced on both sides of [`crate::ProtocolStateStorage`]: the SDK refuses to
699/// write a larger record, and refuses to *deserialize* a larger one on restore
700/// (dropping it instead), so a corrupted or tampered state file cannot turn
701/// into an unbounded allocation during initialization.
702///
703/// Sized above [`MAX_PENDING_MESSAGE_BYTES_PER_PEER`] with room for JSON and
704/// seal overhead. That was once tight rather than generous: under the
705/// per-recipient pending layout the largest legitimate record *was* one peer's
706/// full queue, so the two constants were one bound apart. Now that pending
707/// messages are keyed per id the largest legitimate record is a single message
708/// ([`MAX_MESSAGE_CONTENT_BYTES`] plus [`MAX_RICH_EXTRAS_BYTES`]) or one outbox
709/// entry, and the headroom is real. Keep the ordering anyway — a legacy
710/// per-recipient record still has to be readable for long enough to migrate it.
711pub(crate) const MAX_PROTOCOL_STATE_RECORD_BYTES: usize = 4 * 1024 * 1024;
712
713/// Maximum number of peers remembered in `key_package_sent_to` (the "already
714/// sent our key package to this peer" set).
715///
716/// Wire-claimed ids grow this set in lockstep with a key-package flood, so it
717/// resets at capacity like [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`]: the only cost
718/// of forgetting a peer is a one-time idempotent re-send of our key package.
719pub(crate) const MAX_KEY_PACKAGE_SENT_TO: usize = 1000;
720
721/// Maximum number of peers tracked in `rekey_due_at` (the per-peer epoch-desync
722/// re-key floor).
723///
724/// The desync classification that reaches `schedule_session_rekey` is produced
725/// by OpenMLS *before* it authenticates anything, so the peer id keying this map
726/// is wire-claimed. The envelope/sender binding in `SessionManager::decrypt_message`
727/// already confines that id to peers we hold a session with, which bounds the map
728/// on its own; this cap is the same defence-in-depth every other wire-keyed map
729/// carries. Resets at capacity like [`MAX_KEY_PACKAGE_SENT_TO`] — forgetting a
730/// peer only costs one extra re-key being allowed through early.
731pub(crate) const MAX_REKEY_TRACKED_PEERS: usize = 1000;
732
733/// Maximum lifetime (in milliseconds) honored for a *received* peer key
734/// package's cached expiry.
735///
736/// `remaining_lifetime_ms` arrives on the wire unauthenticated and becomes the
737/// eviction sort key for [`MAX_PENDING_KEY_PACKAGES`] (the soonest-to-expire
738/// entry is evicted first). Without a ceiling, a flood of forged senders
739/// claiming a maximal lifetime would pin their entries as latest-to-expire and
740/// preferentially evict legitimate peers. Key packages are minted with a 30-day
741/// lifetime (`DEFAULT_KEY_PACKAGE_LIFETIME_SECS` in `offline-protocol-mls`), so
742/// a larger value is never legitimate. This bound is purely cache bookkeeping —
743/// OpenMLS enforces real key-package validity at use time — so clamping only
744/// affects when we drop the *cached* copy, never crypto correctness.
745pub(crate) const MAX_KEY_PACKAGE_LIFETIME_MS: u64 = 30 * 24 * 60 * 60 * 1000;
746
747/// Durable record that a peer has proved it runs MLS.
748///
749/// Written whenever a control message from that peer verifies — signature
750/// valid, and the signing key derives to the address the frame claims — and
751/// read back on `initialize_mls` to reseed
752/// [`OfflineProtocol::encryption_capable_peers`](crate::OfflineProtocol).
753///
754/// The timestamp is diagnostic, not a policy input. Its predecessor
755/// (`TofuEntry.last_seen_ms`) drove LRU eviction of a bounded pin store; there
756/// is no eviction here, because there is no longer any per-peer *secret* whose
757/// size needs bounding — only the fact, which the restore walk bounds on its
758/// own terms. Keeping the field costs nothing and makes a stale store readable
759/// by a human.
760#[derive(Clone, Debug, Serialize, Deserialize)]
761pub(crate) struct EncryptionCapableEntry {
762 /// Milliseconds since epoch (UTC) when this peer last proved it runs MLS.
763 pub(crate) last_seen_ms: i64,
764 /// Whether this peer has ever presented a control-frame signature over the
765 /// freshness-bound payload (`offline-ctrl-v2`).
766 ///
767 /// A **ratchet**, and the only reason the freshness check cannot be
768 /// side-stepped: without it an attacker replays a capture from before the
769 /// peer upgraded, the older payload verifies exactly as it always did, and
770 /// the whole check is bypassed by choosing an old enough recording. Once
771 /// this is set, that peer's v1 control frames are refused.
772 ///
773 /// It rides here rather than in [`PeerCapabilities`] because the two record
774 /// different things. That one holds what a peer *advertised*, is
775 /// overwritten wholesale by each fresh key package, and answers "what
776 /// should we send them"; this one holds what a peer *proved*, must never be
777 /// cleared by anything a network attacker can cause, and answers "what will
778 /// we accept from them". Storing it in a record that is routinely
779 /// overwritten is how a ratchet silently stops being one.
780 ///
781 /// `#[serde(default)]` gives `false` for records written before the field
782 /// existed, which is correct: an install that has never verified a v2
783 /// signature from a peer has not proved anything about them.
784 #[serde(default)]
785 pub(crate) ctrl_freshness_proved: bool,
786 /// The signed timestamp of the most recent session reset acted on from
787 /// this peer, or `0` if none.
788 ///
789 /// A high-water mark, and what turns the freshness *window* into an actual
790 /// closure for the one directive that destroys state. The window alone
791 /// bounds a captured reset to thirty days; it does not stop the frame
792 /// being spent inside them, and the receive deduplicator forgets a message
793 /// id after an hour. Without this, a recording tears the pair's session
794 /// down once an hour for a month.
795 ///
796 /// Only ever read from and written by a frame whose timestamp is *inside
797 /// the signature*. On a frame signed under the older payload the timestamp
798 /// is attacker-rewritable, so honouring one here would let anyone park
799 /// this mark at `i64::MAX` and permanently deny that peer the ability to
800 /// heal a forked session, which is a worse failure than the one being
801 /// fixed.
802 #[serde(default)]
803 pub(crate) last_reset_ms: i64,
804}
805
806/// Payload for key package exchange, and the compact envelope version it
807/// advertises.
808///
809/// Both live in `offline-protocol-sealed`: a leaf node builds and reads this
810/// payload with a different MLS implementation, and two ends that disagree
811/// about its shape cannot negotiate anything. Re-exported here so every
812/// existing use site is unchanged.
813pub(crate) use offline_protocol_sealed::{KeyPackagePayload, MLS_ENVELOPE_COMPACT_V1};
814
815/// Sealed rich-payload version advertised in
816/// [`KeyPackagePayload::rich_versions`]: the decrypted MLS plaintext may be
817/// `__RICH_V1__` + JSON of [`RichPayloadV1`], carrying reply context, rich
818/// media metadata, and forward attribution inside the AEAD boundary instead
819/// of on the relay-visible outer message.
820pub(crate) const RICH_PAYLOAD_V1: u8 = 1;
821
822/// Upper bound on the serialized size of the rich extras accepted by the
823/// rich send surface. Enforced at the API boundary (`send_message_with`),
824/// deliberately NOT at seal time: a message queued behind session
825/// establishment re-makes the seal decision at flush, and a seal-time
826/// failure there would re-queue the message forever. Bounding at the
827/// boundary means every queued extras blob is already known to seal.
828pub(crate) const MAX_RICH_EXTRAS_BYTES: usize = 32 * 1024;
829
830/// Replicated-document sync version advertised in
831/// [`KeyPackagePayload::data_versions`]: the decrypted MLS plaintext may be
832/// `__DATA_V1__` + JSON of a sync frame (version-vector exchange, delta
833/// push, or snapshot offer).
834///
835/// One number covers both the frame schema and the document encoding the
836/// frames carry. Splitting them would let a peer advertise a frame schema it
837/// speaks around an encoding it cannot read, and the failure would surface
838/// as an unreadable document rather than as a refused negotiation.
839pub(crate) const DATA_SYNC_V1: u8 = 1;
840
841/// Group-space replication, advertised in
842/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
843/// sender intercepts `__DATA_V1__` frames arriving inside a *group*
844/// ciphertext, not only inside a 1:1 one.
845///
846/// A separate entry rather than a bump, and the reason is a compatibility
847/// trap rather than tidiness. A build that speaks only [`DATA_SYNC_V1`]
848/// advertises it, holds group sessions, and has no group interception at
849/// all: every group path emits whatever decrypts as a chat message, so a
850/// group sync frame sent to one would surface to its user as literal
851/// `__DATA_V1__{...}` text. Gating group sends on this entry is what keeps
852/// that frame from ever being sent, and appending rather than replacing is
853/// what keeps 1:1 replication working with those same peers.
854pub(crate) const DATA_GROUP_V1: u8 = 2;
855
856/// Attachment and snapshot carriage over the media path, advertised in
857/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
858/// sender understands the `need_blob` and `blob_gone` frame kinds, and
859/// routes a media transfer marked as data-purposed into the data layer
860/// instead of surfacing it to its user as a received file.
861///
862/// A third entry rather than a bump, for the reason the second one exists.
863/// A build speaking only [`DATA_SYNC_V1`] replicates documents correctly and
864/// has no arm for either new frame kind; it consumes them (the parser reads
865/// the version first and the body second, so an unknown kind is dropped
866/// rather than surfaced) but answers nothing, so a fetch aimed at it waits
867/// forever. Worse, such a build receiving a data-purposed media transfer
868/// emits `FileReceived` and hands a person a CRDT snapshot as a downloaded
869/// file. Gating on this entry is what stops both.
870///
871/// v1 carries blobs on 1:1 sessions only, so this entry has no attested
872/// sibling: group members never exchange key packages with each other, and
873/// there is nothing about group carriage for an inviter to attest.
874pub(crate) const DATA_MEDIA_V1: u8 = 3;
875
876/// Document removals, advertised in [`KeyPackagePayload::data_versions`]
877/// alongside [`DATA_SYNC_V1`]: the sender reads the `gone` field of a
878/// version offer, and removes what a floor covers instead of offering it
879/// back.
880///
881/// A fourth entry rather than a bump, for the reason the second and third
882/// exist, and here the compatibility story is unusually gentle. A build
883/// without this entry ignores an unknown field on a frame kind it knows, so
884/// it keeps its copy and re-offers it forever; this device refuses each
885/// offer and each blob against its own floor, so the removal still holds
886/// everywhere it is known. Nothing is surfaced wrongly and nothing loops:
887/// the cost is one offer per exchange that is answered with nothing.
888///
889/// Gating the *field* on the entry is therefore a traffic decision rather
890/// than a safety one, which is why a group carries it ungated: one
891/// ciphertext reaches the whole roster, so there is no per-member choice to
892/// make, and the roster-wide gate on [`DATA_GROUP_V1`] already decides
893/// whether the frame may be sent at all.
894pub(crate) const DATA_TOMBSTONE_V1: u8 = 4;
895
896/// Interest-scoped replication, advertised in
897/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
898/// sender reads the `want` field of a version offer and answers only the
899/// documents it names.
900///
901/// A fifth entry rather than a bump, and like the fourth it gates traffic
902/// rather than correctness. A peer without it ignores the field and answers
903/// with everything it holds, and the asking side refuses what it did not ask
904/// for on arrival, so the narrowing holds regardless; what it does not do is
905/// save the radio. Gating means a narrowed space says so only where saying so
906/// changes what comes back.
907///
908/// The half that does not depend on this entry is the local refusal, and it
909/// is the reason interest is safe to ship before any peer speaks it.
910pub(crate) const DATA_INTEREST_V1: u8 = 5;
911
912/// Attachment bytes inside a group, advertised in
913/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
914/// sender answers a `need_blob` that arrives inside a group ciphertext, and
915/// carries the bytes back as `chunk` frames under the group key.
916///
917/// A sixth entry rather than a bump, and the first `data_versions` entry
918/// since the second with an attested sibling. [`DATA_MEDIA_V1`] has none
919/// because carriage was 1:1 and there was nothing about it for a group
920/// inviter to attest; this one is about a group, so there is.
921///
922/// What it gates is a better answer to a question, not safety. A member
923/// without it receives a `need_blob` it understands the shape of and
924/// refuses, and receives `chunk` frames whose kind it does not know and
925/// drops: the version is read before the body, so an unknown kind is
926/// consumed rather than surfaced. Nothing is shown to anybody wrongly. What
927/// the entry buys is refusing a fetch at the call instead of leaving an
928/// application waiting out the silence timeout for an answer that was never
929/// coming.
930pub(crate) const DATA_GROUP_BLOB_V1: u8 = 6;
931
932/// Rich fields accepted by the `send_message_with` surface. Only ever
933/// delivered inside the sealed [`RichPayloadV1`] body — toward a recipient
934/// that did not advertise [`RICH_PAYLOAD_V1`] they are silently dropped,
935/// never sent cleartext.
936#[derive(Debug, Clone, Default, Serialize, Deserialize)]
937pub(crate) struct RichSendExtras {
938 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub(crate) reply_context: Option<ReplyContext>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub(crate) media_metadata: Option<MediaMetadata>,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub(crate) forward_info: Option<ForwardInfo>,
944}
945
946impl RichSendExtras {
947 /// Whether any rich field is present (empty extras never seal).
948 pub(crate) fn is_any(&self) -> bool {
949 self.reply_context.is_some() || self.media_metadata.is_some() || self.forward_info.is_some()
950 }
951
952 /// Enforces [`MAX_RICH_EXTRAS_BYTES`] on the serialized extras. Shared
953 /// by every rich send boundary (`send_message_with`, the forward paths,
954 /// and the group surface) — enforced there, never at seal time, so a
955 /// queued or re-sent message is always known to seal (see the
956 /// constant's doc). Empty extras always pass.
957 pub(crate) fn check_size(&self) -> crate::Result<()> {
958 if !self.is_any() {
959 return Ok(());
960 }
961 let extras_len = serde_json::to_vec(self)
962 .map_err(|e| Error::Serialization(e.to_string()))?
963 .len();
964 if extras_len > MAX_RICH_EXTRAS_BYTES {
965 return Err(Error::InvalidArgument(format!(
966 "Rich extras too large: {} bytes serialized (max {})",
967 extras_len, MAX_RICH_EXTRAS_BYTES
968 )));
969 }
970 Ok(())
971 }
972}
973
974/// Options for `OfflineProtocol::send_message_with`: priority and reply
975/// threading (as on `send_message`), plus the rich fields introduced with
976/// the sealed rich payload.
977///
978/// The rich fields (`reply_context`, `media_metadata`, `forward_info`) only
979/// ever travel inside the MLS-sealed `__RICH_V1__` body, and only toward
980/// recipients whose key package advertised `rich_versions` support. Toward
981/// anyone else they are silently dropped — never sent cleartext — so the
982/// message degrades to plain text with `reply_to_msg` threading intact.
983#[derive(Debug, Clone, Default)]
984pub struct SendMessageOptions {
985 /// Message priority (defaults to Medium).
986 pub priority: Option<MessagePriority>,
987 /// ID of the message this is replying to.
988 pub reply_to_msg: Option<String>,
989 /// Content type stamped on the outer message (defaults to Text). A
990 /// coarse rendering hint — the actual content stays MLS-sealed. Toward
991 /// a recipient that advertised the sealed rich payload, a copy travels
992 /// inside the sealed body — whenever extras seal, or the hint itself is
993 /// non-Text — and the receiver treats that copy as authoritative, so a
994 /// relay cannot rewrite the hint. Must not be
995 /// [`ContentType::FileChunk`] (an internal transport content type; the
996 /// receiver would route the message into its file-transfer manager and
997 /// drop it) — rejected as `InvalidArgument`.
998 pub content_type: Option<ContentType>,
999 /// Quoted-reply context, delivered sealed-only.
1000 pub reply_context: Option<ReplyContext>,
1001 /// Rich media metadata (cloud attachments, stickers — including any
1002 /// `encryption_key`/`iv` secrets), delivered sealed-only.
1003 pub media_metadata: Option<MediaMetadata>,
1004 /// Forward attribution, delivered sealed-only.
1005 pub forward_info: Option<ForwardInfo>,
1006 /// Send via this specific transport (bypassing DORS selection), like
1007 /// `send_message_via_transport`.
1008 pub via_transport: Option<TransportType>,
1009}
1010
1011/// Options for `OfflineProtocol::send_media_with`: the chunk-0 media
1012/// metadata (as on `send_media`), plus the rich fields introduced with the
1013/// sealed rich payload and an optional caller-supplied `file_id`.
1014///
1015/// The rich fields (`caption`, `reply_to_msg`, `reply_context`,
1016/// `forward_info`) only ever travel inside the MLS-sealed chunk-0 plaintext
1017/// (v2 media envelope), and only toward recipients whose key package
1018/// advertised `rich_versions` support. Toward anyone else — including every
1019/// plaintext (encryption opt-out) transfer — they are silently dropped,
1020/// never sent cleartext, so the transfer degrades to what plain
1021/// `send_media` sends.
1022#[derive(Debug, Clone, Default)]
1023pub struct MediaSendOptions {
1024 /// Media metadata delivered with chunk 0 (as on `send_media`).
1025 pub media_metadata: Option<MediaMetadata>,
1026 /// Caption text, delivered sealed-only.
1027 pub caption: Option<String>,
1028 /// ID of the message this media replies to, delivered sealed-only.
1029 pub reply_to_msg: Option<String>,
1030 /// Quoted-reply context, delivered sealed-only.
1031 pub reply_context: Option<ReplyContext>,
1032 /// Forward attribution, delivered sealed-only.
1033 pub forward_info: Option<ForwardInfo>,
1034 /// Caller-supplied file id for the transfer (minted when absent). Must
1035 /// not collide with an active outbound transfer; bounded to the wire
1036 /// `file_id` field limit.
1037 pub file_id: Option<String>,
1038}
1039
1040/// The sealed rich body: what `__RICH_V1__` + JSON carries inside the MLS
1041/// plaintext. `text` is the user-visible content; the optional fields are
1042/// restored onto the inbound message by `apply_decrypted_content` *after*
1043/// its outer-field strip, making the sealed body the sole trusted carrier
1044/// for rich data on encrypted messages.
1045#[derive(Clone, Serialize, Deserialize)]
1046pub(crate) struct RichPayloadV1 {
1047 pub(crate) text: String,
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub(crate) reply_context: Option<ReplyContext>,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub(crate) media_metadata: Option<MediaMetadata>,
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1053 pub(crate) forward_info: Option<ForwardInfo>,
1054 /// Sealed copy of the outer `content_type` rendering hint — the last
1055 /// rich-adjacent field a relay could rewrite in transit. Additive after
1056 /// the body first shipped: absent from bodies sealed by older senders,
1057 /// in which case the outer value stands. When present it is
1058 /// authoritative on receive (except `FileChunk`, refused there like at
1059 /// the send boundary), so a relay can no longer restamp the rendering
1060 /// hint — or worse, restamp it `FileChunk` and get the decrypted
1061 /// message routed into the file-transfer manager and dropped. Fresh
1062 /// sends with a non-Text hint seal a hint-only body even without
1063 /// extras. Forwards seal their attribution and media metadata as
1064 /// extras toward capable recipients; only legacy queued forwards
1065 /// (persisted by older builds, outer-only) skip the hint-only seal,
1066 /// since a sealed body would wipe their outer copies on restore.
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub(crate) content_type: Option<ContentType>,
1069}
1070
1071impl RichPayloadV1 {
1072 /// Parses a sealed `__RICH_V1__` body out of decrypted MLS plaintext.
1073 ///
1074 /// Shared by the DM restore (`apply_decrypted_content`) and the group
1075 /// message inbound paths. Never capability-gated (mirroring envelope
1076 /// parsing): whatever a peer chose to seal, we try to read. Returns
1077 /// `None` when the prefix is absent or the body fails to parse (logged;
1078 /// callers surface the raw text rather than dropping an authenticated
1079 /// message). A sealed `FileChunk` content-type claim is refused here —
1080 /// mirroring the send boundary — so a hostile sender can never steer a
1081 /// decrypted message into the file-transfer manager, which would drop
1082 /// it.
1083 pub(crate) fn parse_sealed(plaintext: &str, sender: &str) -> Option<Self> {
1084 let sealed = plaintext.strip_prefix(super::internal_prefixes::RICH_V1)?;
1085 match serde_json::from_str::<Self>(sealed) {
1086 Ok(mut rich) => {
1087 if rich.content_type == Some(ContentType::FileChunk) {
1088 warn!(
1089 sender = %sender,
1090 "Sealed rich payload claims the internal FileChunk content type, ignoring the hint"
1091 );
1092 rich.content_type = None;
1093 }
1094 Some(rich)
1095 }
1096 Err(e) => {
1097 warn!(
1098 sender = %sender,
1099 error = %e,
1100 "Failed to parse sealed rich payload, surfacing raw text"
1101 );
1102 None
1103 }
1104 }
1105 }
1106}
1107
1108/// An outbound connection request awaiting a transport outcome (see
1109/// `OfflineProtocol::pending_connection_requests`).
1110#[derive(Debug, Clone)]
1111pub(crate) struct PendingConnectionRequest {
1112 /// Recipient the request was addressed to.
1113 pub(crate) recipient: String,
1114 /// When the request was sent — entries older than
1115 /// [`PENDING_CONNECTION_REQUEST_TTL`] are pruned on insert.
1116 pub(crate) sent_at: std::time::Instant,
1117}
1118
1119/// How long an outbound connection request stays correlatable to a
1120/// transport failure. Past this window the entry is pruned: a DeliveryError
1121/// that stale belongs to a request the app has long stopped showing a
1122/// spinner for.
1123///
1124/// Deliberately wider than the bridges' 60s `RecipientInFlightTracker` TTL:
1125/// that window anchors at the socket write, while this one anchors at
1126/// `send_connection_request` — a request can dwell in the internet outbox
1127/// (device offline, relay reconnecting) before its wire attempt, and this
1128/// window must cover that dwell plus the bridge's correlation window.
1129///
1130/// Also deliberately wider than the worst-case default ACK retry schedule
1131/// (10 retries, 1s initial delay, x2 backoff capped at 5 min, plus a 10s
1132/// ACK timeout per attempt — up to ~910s end to end): retry exhaustion is
1133/// the last settlement point that can still emit a typed undeliverable
1134/// event, so the window must outlive it with headroom.
1135pub(crate) const PENDING_CONNECTION_REQUEST_TTL: std::time::Duration =
1136 std::time::Duration::from_secs(1800);
1137
1138/// Cap on tracked outbound connection requests (oldest evicted first).
1139pub(crate) const MAX_PENDING_CONNECTION_REQUESTS: usize = 64;
1140
1141/// Upper bound (UTF-8 bytes) for `initial_message` on a connection request.
1142/// The request is a plaintext High-priority control frame; an unbounded
1143/// first message would fragment heavily over BLE and can exceed relay frame
1144/// limits after the SDK already returned a message id, so oversized input
1145/// fails loudly at the API instead.
1146pub(crate) const MAX_INITIAL_MESSAGE_BYTES: usize = 4096;
1147
1148/// Payload for a connection request message.
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1150pub(crate) struct ConnectionRequestPayload {
1151 /// Display name of the sender.
1152 pub(crate) sender_name: String,
1153 /// Timestamp of the request (Unix ms).
1154 pub(crate) timestamp_ms: i64,
1155 /// Optional MLS key package data for encrypted session setup.
1156 #[serde(skip_serializing_if = "Option::is_none")]
1157 pub(crate) key_package: Option<Vec<u8>>,
1158 /// Optional first message sent along with the request (`default` keeps
1159 /// payloads from pre-initial-message senders parseable).
1160 #[serde(default, skip_serializing_if = "Option::is_none")]
1161 pub(crate) initial_message: Option<String>,
1162}
1163
1164/// Payload for a connection accepted message.
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1166pub(crate) struct ConnectionAcceptedPayload {
1167 /// Display name of the accepting party.
1168 pub(crate) accepted_by_name: String,
1169 /// Timestamp of the acceptance (Unix ms).
1170 #[serde(default)]
1171 pub(crate) timestamp_ms: i64,
1172 /// Optional MLS key package data for encrypted session setup.
1173 #[serde(skip_serializing_if = "Option::is_none")]
1174 pub(crate) key_package: Option<Vec<u8>>,
1175}
1176
1177// --- Presence, typing, and read receipt payloads ---
1178
1179/// Maximum number of message IDs allowed in a single read receipt.
1180pub(crate) const MAX_READ_RECEIPT_IDS: usize = 256;
1181
1182/// Payload for a presence update message.
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1184pub(crate) struct PresencePayload {
1185 /// Presence status.
1186 pub(crate) status: PresenceStatus,
1187 /// Timestamp of the update (Unix ms).
1188 pub(crate) timestamp_ms: i64,
1189}
1190
1191/// Payload for a typing indicator message.
1192#[derive(Debug, Clone, Serialize, Deserialize)]
1193pub(crate) struct TypingIndicatorPayload {
1194 /// Opaque conversation identifier chosen by the sender (conventionally a
1195 /// peer address for DMs, the group id for groups). Never parsed here.
1196 pub(crate) conversation_id: String,
1197 /// Whether the user is currently typing.
1198 pub(crate) is_typing: bool,
1199 /// Timestamp of the indicator (Unix ms).
1200 pub(crate) timestamp_ms: i64,
1201}
1202
1203/// Payload for a read receipt message.
1204#[derive(Debug, Clone, Serialize, Deserialize)]
1205pub(crate) struct ReadReceiptPayload {
1206 /// IDs of the messages that were read.
1207 pub(crate) message_ids: Vec<String>,
1208 /// Timestamp when the messages were read (Unix ms).
1209 pub(crate) timestamp_ms: i64,
1210}
1211
1212// --- Group (relay) payloads ---
1213
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1215pub(crate) struct GroupCreatedPayload {
1216 pub(crate) group_id: String,
1217 pub(crate) name: String,
1218}
1219
1220#[derive(Debug, Clone, Serialize, Deserialize)]
1221pub(crate) struct GroupMessageReceivedPayload {
1222 pub(crate) group_id: String,
1223 pub(crate) sender: String,
1224 pub(crate) content: String,
1225 pub(crate) timestamp: String,
1226 pub(crate) message_id: String,
1227 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub(crate) reply_to_msg: Option<String>,
1229 /// Forwarding attribution (present when the group message was forwarded).
1230 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
1232}
1233
1234#[derive(Debug, Clone, Serialize, Deserialize)]
1235pub(crate) struct GroupMemberAddedPayload {
1236 pub(crate) group_id: String,
1237 pub(crate) user_id: String,
1238 pub(crate) added_by: String,
1239 #[serde(skip_serializing_if = "Option::is_none")]
1240 pub(crate) group_name: Option<String>,
1241}
1242
1243#[derive(Debug, Clone, Serialize, Deserialize)]
1244pub(crate) struct GroupMemberRemovedPayload {
1245 pub(crate) group_id: String,
1246 pub(crate) user_id: String,
1247 pub(crate) removed_by: String,
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize)]
1251pub(crate) struct GroupInfoMemberPayload {
1252 pub(crate) user_id: String,
1253 pub(crate) role: String,
1254 pub(crate) joined_at: String,
1255}
1256
1257#[derive(Debug, Clone, Serialize, Deserialize)]
1258pub(crate) struct GroupInfoPayload {
1259 pub(crate) group_id: String,
1260 pub(crate) name: String,
1261 pub(crate) created_by: String,
1262 pub(crate) created_at: String,
1263 pub(crate) members: Vec<GroupInfoMemberPayload>,
1264}
1265
1266#[derive(Debug, Clone, Serialize, Deserialize)]
1267pub(crate) struct UserGroupSummaryPayload {
1268 pub(crate) group_id: String,
1269 pub(crate) name: String,
1270 pub(crate) created_at: String,
1271}
1272
1273#[derive(Debug, Clone, Serialize, Deserialize)]
1274pub(crate) struct UserGroupsPayload {
1275 pub(crate) groups: Vec<UserGroupSummaryPayload>,
1276}
1277
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279pub(crate) struct GroupErrorPayload {
1280 /// The relay's own wording. **Never emitted on an event** — see
1281 /// [`GroupErrorPayload::classify_reason`]. Treat every read of this
1282 /// field as a read of untrusted, unbounded wire input.
1283 pub(crate) reason: String,
1284 /// Group the error concerns, when the relay scoped it (e.g. a
1285 /// registration sync denial). Used to drop the group from
1286 /// `relay_synced` so sends fall back to per-member delivery.
1287 #[serde(default, skip_serializing_if = "Option::is_none")]
1288 pub(crate) group_id: Option<String>,
1289}
1290
1291impl GroupErrorPayload {
1292 /// Maps the relay's free-text `reason` onto a fixed local vocabulary.
1293 ///
1294 /// `__GROUP_ERROR__` is a relay answer: it rides
1295 /// `RELAY_ANSWER_PREFIXES`, so on the relay ingest shape (Internet
1296 /// arrival, no transport peer identity) it is accepted **unsigned** —
1297 /// no key material, no session, no prior contact, just a frame on that
1298 /// socket. Signed, it is reachable to any peer over any transport, since
1299 /// the handler is deliberately not gated on the Internet path the way
1300 /// the `GROUP_CREATED` ack is. Either way `reason` is whatever the
1301 /// sender wrote: arbitrary content, arbitrary length, not ours.
1302 ///
1303 /// So the text does not travel any further. Nothing downstream needs it
1304 /// — the sync revocation keys off `group_id`, and the platform bridges
1305 /// already dual-emit the raw relay frame on the server-message channel
1306 /// for apps that want the relay's exact wording. What an event carries
1307 /// is this classification instead, which an attacker can at most steer
1308 /// between three harmless codes.
1309 ///
1310 /// Even an *honest* relay makes this necessary: its `Not a member of
1311 /// group {id}` renders an identifier into prose, smuggling past a
1312 /// telemetry scrubber that hashes `group_id` fields but ships free text
1313 /// verbatim by design.
1314 ///
1315 /// The `&'static str` return is load-bearing, the same way
1316 /// `MlsError::privacy_safe_reason` is: it makes interpolating wire input
1317 /// unrepresentable rather than merely discouraged. Matching is exact and
1318 /// the fallback is closed, so a relay rewording an error degrades to
1319 /// `error` — never back to shipping its text.
1320 pub(crate) fn classify_reason(&self) -> &'static str {
1321 match self.reason.as_str() {
1322 // Relay has no such group: invite links and relay fan-out for it
1323 // are dead, not merely refused.
1324 "Group not found" => "not_found",
1325 // Relay refused to register/sync the group for this caller.
1326 "Only admins can sync this group" => "sync_denied",
1327 // Everything else, including anything a forged frame chose.
1328 _ => "error",
1329 }
1330 }
1331}
1332
1333/// A received key package awaiting use for session creation.
1334#[derive(Debug, Clone, Serialize, Deserialize)]
1335pub(crate) struct ReceivedKeyPackage {
1336 /// Raw MLS key package bytes.
1337 pub(crate) key_package_data: Vec<u8>,
1338 /// Local wall-clock deadline (ms since epoch) computed from the sender's
1339 /// `remaining_lifetime_ms`, anchored to *our* clock at receive time.
1340 pub(crate) local_expires_at_ms: u64,
1341}
1342
1343/// Durable record of the end-to-end capability versions a peer last
1344/// advertised in its key package (`env_versions` / `rich_versions`).
1345///
1346/// Persisted separately from [`ReceivedKeyPackage`] because the cached key
1347/// package is deleted once a session is established, while the capabilities
1348/// must survive restarts for exactly those peers: mobile apps restart
1349/// constantly and MLS sessions persist, so without this record a rich send
1350/// right after relaunch silently degrades to bare text (and the compact
1351/// envelope to JSON) until the next live key-package exchange.
1352///
1353/// Stores the raw advertised versions, not the config-gated subset: the kill
1354/// switches (`compact_envelope_enabled` / `rich_payload_enabled`) gate use —
1355/// live recording, restore, and send — not knowledge, so toggling one across
1356/// restarts behaves the same as toggling it live.
1357///
1358/// `wire_versions` is deliberately absent: it is hop-local (which frames a
1359/// directly-connected peer decodes), and connection setup re-exchanges key
1360/// packages on discovery anyway, so persisting it would buy nothing.
1361#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1362pub(crate) struct PeerCapabilities {
1363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1364 pub(crate) env_versions: Vec<u8>,
1365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1366 pub(crate) rich_versions: Vec<u8>,
1367 /// Sync versions the peer advertised, from
1368 /// [`KeyPackagePayload::data_versions`]. Persisted for the same reason
1369 /// as the two above: without it a document edited right after relaunch
1370 /// would not replicate until the next live key-package exchange, and a
1371 /// sync that silently does not happen looks exactly like a peer with
1372 /// nothing to say.
1373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1374 pub(crate) data_versions: Vec<u8>,
1375 /// Rich-payload versions a *group inviter* attested for this peer
1376 /// (carried on the group Add commit / Welcome), as opposed to the
1377 /// direct self-advertised `rich_versions` above. Kept separate because
1378 /// the trust differs: attestation is third-party and may be stale, so a
1379 /// direct key-package exchange overwrites the whole record (clearing
1380 /// this field) — direct knowledge is always authoritative. Consulted
1381 /// only by the group seal gate, never by DM sealing or envelope
1382 /// selection.
1383 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1384 pub(crate) attested_rich_versions: Vec<u8>,
1385 /// Sync versions a *group inviter* attested for this peer, carried on
1386 /// the group Add commit / Welcome exactly like
1387 /// [`Self::attested_rich_versions`] and kept separate from the direct
1388 /// `data_versions` above for the same reason: attestation is
1389 /// third-party and may be stale, so a direct key-package exchange
1390 /// overwrites the whole record and clears this field.
1391 ///
1392 /// Group members never exchange key packages with each other, so
1393 /// without this a group would replicate only among the subset of
1394 /// members that happen to have met directly, and the rest would sit
1395 /// there editing documents nobody receives. Consulted only by the
1396 /// group replication gate, never by 1:1 sync (which always has direct
1397 /// knowledge: session establishment exchanges key packages).
1398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1399 pub(crate) attested_data_versions: Vec<u8>,
1400
1401 /// Control-frame signing versions the peer advertised, from
1402 /// [`KeyPackagePayload::ctrl_versions`]. Persisted for the same reason as
1403 /// the lists above: without it, a peer met before a restart would be sent
1404 /// the older signing payload until the next live key-package exchange, and
1405 /// a peer that has *proved* it signs the newer one refuses that.
1406 ///
1407 /// This is the advertised half only. What the peer proved lives in
1408 /// [`EncryptionCapableEntry::ctrl_freshness_proved`], deliberately in a
1409 /// record this one's wholesale overwrite cannot reach.
1410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1411 pub(crate) ctrl_versions: Vec<u8>,
1412
1413 /// The peer's Nostr public key, from [`KeyPackagePayload::nostr_pubkey`].
1414 ///
1415 /// Persisted for the same reason as the capability lists: the cached key
1416 /// package is deleted once a session is established, and a peer met before
1417 /// a restart would otherwise silently fall back to bootstrap-key sealing —
1418 /// a privacy regression with no symptom — until the next live exchange.
1419 #[serde(default, skip_serializing_if = "Option::is_none")]
1420 pub(crate) nostr_pubkey: Option<String>,
1421}
1422
1423/// Length of an x-only secp256k1 public key in lowercase hex.
1424pub(crate) const NOSTR_PUBKEY_HEX_LEN: usize = 64;
1425
1426/// Validates a wire-supplied Nostr public key before it is persisted or used
1427/// as a sealing destination.
1428///
1429/// Shape only — the value is signature-bound to the sender, so this is not an
1430/// authenticity check. It exists so a malformed or oversized string cannot
1431/// enter a durable record, and it normalizes case so the same key never
1432/// persists under two spellings.
1433pub(crate) fn normalize_nostr_pubkey(candidate: &str) -> Option<String> {
1434 if candidate.len() != NOSTR_PUBKEY_HEX_LEN || !candidate.bytes().all(|b| b.is_ascii_hexdigit())
1435 {
1436 return None;
1437 }
1438 Some(candidate.to_ascii_lowercase())
1439}
1440
1441/// Cap on how many advertised version entries persist per capability list
1442/// in a [`PeerCapabilities`] record. The lists are unauthenticated wire
1443/// input stored raw (raw so unknown future versions survive a local
1444/// upgrade), and a hostile advertiser could otherwise bloat each durable
1445/// record up to the transport message size. Truncation only hurts the
1446/// advertiser — real senders list a handful of versions.
1447pub(crate) const MAX_PERSISTED_CAPABILITY_VERSIONS: usize = 8;
1448
1449impl PeerCapabilities {
1450 /// Builds a record from the wire-advertised version lists, truncating
1451 /// each to [`MAX_PERSISTED_CAPABILITY_VERSIONS`].
1452 pub(crate) fn from_advertised(
1453 env_versions: &[u8],
1454 rich_versions: &[u8],
1455 data_versions: &[u8],
1456 ctrl_versions: &[u8],
1457 nostr_pubkey: Option<&str>,
1458 ) -> Self {
1459 Self {
1460 env_versions: env_versions
1461 .iter()
1462 .copied()
1463 .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1464 .collect(),
1465 rich_versions: rich_versions
1466 .iter()
1467 .copied()
1468 .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1469 .collect(),
1470 data_versions: data_versions
1471 .iter()
1472 .copied()
1473 .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1474 .collect(),
1475 ctrl_versions: ctrl_versions
1476 .iter()
1477 .copied()
1478 .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1479 .collect(),
1480 attested_rich_versions: Vec::new(),
1481 attested_data_versions: Vec::new(),
1482 nostr_pubkey: nostr_pubkey.and_then(normalize_nostr_pubkey),
1483 }
1484 }
1485
1486 /// Whether any capability is advertised. Empty records are deleted
1487 /// rather than stored — the durable side of the downgrade semantics.
1488 pub(crate) fn is_any(&self) -> bool {
1489 !self.env_versions.is_empty()
1490 || !self.rich_versions.is_empty()
1491 || !self.data_versions.is_empty()
1492 || !self.ctrl_versions.is_empty()
1493 || !self.attested_rich_versions.is_empty()
1494 || !self.attested_data_versions.is_empty()
1495 || self.nostr_pubkey.is_some()
1496 }
1497}
1498
1499/// Which canonical payload a control frame's signature verified against.
1500///
1501/// Both are legitimate: [`Self::V1`] is what every peer signed before the
1502/// freshness-bound payload existed, and refusing it outright would refuse
1503/// first contact with any peer that has not upgraded. What the distinction
1504/// buys is that the two can be treated differently once a peer has *proved* it
1505/// can produce the newer one.
1506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1507pub(crate) enum ControlSigVersion {
1508 /// `offline-ctrl-v1`: sender, id, recipient, content. States no freshness,
1509 /// so a signature over it never expires.
1510 V1,
1511 /// `offline-ctrl-v2`: the four fields above plus the frame's timestamp.
1512 V2,
1513}
1514
1515/// What verifying a control frame's signature established.
1516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1517pub(crate) enum ControlVerification {
1518 /// No signature metadata at all. The caller decides policy; for a
1519 /// security-gated prefix that policy is refusal.
1520 Unsigned,
1521 /// A signature verified, and the key that made it derives to the address
1522 /// the frame claims to come from.
1523 Verified(ControlSigVersion),
1524}
1525
1526/// Outcome of [`OfflineProtocol::security_gate_control_message`].
1527///
1528/// [`OfflineProtocol`]: crate::OfflineProtocol
1529/// [`OfflineProtocol::security_gate_control_message`]: crate::OfflineProtocol
1530#[derive(Debug)]
1531pub(crate) enum ControlGateOutcome {
1532 /// The message may proceed to dispatch.
1533 ///
1534 /// `signed` is `true` **only** when an Ed25519 signature was present,
1535 /// verified, and produced by the key the sender's address derives from. It
1536 /// is deliberately `false` for both of the other ways a message reaches
1537 /// dispatch — a relay-originated answer, which no peer signs
1538 /// (`RELAY_ANSWER_PREFIXES`), and a prefix the gate does not cover at all —
1539 /// so a handler that keys on it fails closed without having to know which
1540 /// case it is in.
1541 ///
1542 /// Since unsigned control traffic is now refused outright, `false` no
1543 /// longer reaches any *gated* handler; the bit survives because those two
1544 /// ungated paths still produce it, and because a handler that treats a
1545 /// payload field as authenticated should say so at the point it does.
1546 ///
1547 /// `freshness_bound` is the strictly narrower fact: the signature verified
1548 /// against the payload that covers the frame's timestamp, **and** that
1549 /// timestamp was inside the window this node allows. It is what a handler
1550 /// consults before acting on a directive that destroys state, because such
1551 /// a directive on a frame that could be a recording is a replayable way to
1552 /// destroy that state (issue 403).
1553 ///
1554 /// It is deliberately the *fact* and not the *policy*: it stays `false`
1555 /// when freshness enforcement is switched off, and the handler applies the
1556 /// switch itself. A flag that folded the config in would read as "this
1557 /// frame is fresh" while meaning "nobody looked", which is the reading that
1558 /// gets copied to the next call site.
1559 Proceed { signed: bool, freshness_bound: bool },
1560 /// The gate rejected the message; the caller must return this result
1561 /// without dispatching.
1562 Rejected(InternalMessageResult),
1563}
1564
1565/// Result of processing an internal protocol message.
1566#[derive(Debug)]
1567pub(crate) enum InternalMessageResult {
1568 /// Message was consumed internally (don't surface to app).
1569 Consumed,
1570 /// Message was rejected by the security gate (spoofed sender, bad
1571 /// signature, unsigned control traffic, or a signing key that does not
1572 /// derive to the claimed sender address). Like `Consumed`, the message is not
1573 /// surfaced to the app — but unlike `Consumed`, a delivery ACK must NOT
1574 /// be sent back, to avoid confirming to the attacker that the target is
1575 /// online and processing messages.
1576 SecurityRejected,
1577 /// Message was not delivered, but the sender can still recover it by
1578 /// resending. Unlike `Consumed`, a delivery ACK must NOT be sent and the id
1579 /// must NOT stay dedup-marked: the message is provably not delivered, so
1580 /// the receiver must leave the sender's retry lever intact. The receive
1581 /// loop responds by unmarking the id (so a resend re-enters processing
1582 /// instead of hitting the duplicate re-ACK path) and skipping the ACK.
1583 ///
1584 /// Four conditions produce it, differing in whether the *frame* is worth
1585 /// keeping:
1586 ///
1587 /// - **Session not ready**: the MLS session/group is not established yet,
1588 /// so the frame is queued for delayed decryption
1589 /// (`enqueue_pending_decryption`). The queued copy is surfaced — and the
1590 /// id re-marked — once the session confirms and the queue drains
1591 /// (`process_pending_decryption`), which also sends the deferred delivery
1592 /// ACK directly on the recorded arrival transport (so a sender that gave
1593 /// up before the session confirmed still learns of delivery).
1594 /// - **Epoch desync**: the frame is sealed to a dead epoch, so it is *not*
1595 /// queued (it could never drain) and a rate-limited re-key is triggered.
1596 /// - **Crypto/transport failure** with `crypto_recovery_enabled`: OpenMLS
1597 /// consumed the ratchet generation on the failed attempt, so the frame is
1598 /// likewise not queued — and no re-key is triggered, which stays
1599 /// desync-only.
1600 /// - **Envelope parse failure** with `crypto_recovery_enabled`: the
1601 /// `__MLS_ENC__` payload did not parse in any envelope form, so there is
1602 /// no ciphertext to decrypt. Not queued either — an unparseable frame can
1603 /// never become parseable.
1604 ///
1605 /// In the latter three, recovery is the sender's *resend* rather than this
1606 /// frame: Tier 2 re-seals each resend of an encrypted DM against a live
1607 /// generation, and a message that stays undeliverable settles as an honest
1608 /// `MessageFailed` instead of a lying "delivered". See
1609 /// `docs/state-machines/delivery-and-acks.md` for the deferred-acknowledgement
1610 /// atom and the decrypt-failure classification.
1611 Deferred,
1612 /// Message was decrypted, here's the plaintext.
1613 Decrypted(String),
1614}
1615
1616/// Outcome of routing an inbound file-chunk message through
1617/// [`OfflineProtocol::handle_incoming_file_chunk`]. Distinguishes chunks that
1618/// were dealt with terminally (decrypted/assembled, or dropped for a permanent
1619/// reason) from chunks queued for delayed decryption — so the receive loop can
1620/// defer the ACK for the latter, exactly like the text `Deferred` path.
1621///
1622/// [`OfflineProtocol::handle_incoming_file_chunk`]: crate::OfflineProtocol
1623///
1624/// `#[must_use]`: the ACK/defer decision hinges on this outcome. Dropping it on
1625/// the floor silently reverts to the pre-deferred-ACK behavior (always ACK,
1626/// leave dedup-marked), reintroducing the queue-path silent-loss bug — so every
1627/// caller must branch on it.
1628#[must_use]
1629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1630pub(crate) enum ChunkOutcome {
1631 /// The chunk was decrypted/assembled or dropped for a terminal reason
1632 /// (parse failure, resource limit, permanent refusal). The sender should
1633 /// stop retrying either way, so the caller ACKs as before.
1634 Handled,
1635 /// The chunk was not delivered but the sender can still recover it by
1636 /// resending: it could not be decrypted yet (session not ready, so queued
1637 /// for delayed decryption), or it is undecryptable as it stands (epoch
1638 /// desync, or a crypto failure while `crypto_recovery_enabled`), or its
1639 /// media envelope did not decode at all (also `crypto_recovery_enabled`) —
1640 /// the latter shapes dropped without queueing. The caller must NOT ACK and
1641 /// must unmark the id, so the sender keeps retrying and the resend re-enters
1642 /// processing. For an undecryptable or undecodable chunk that recovery is
1643 /// the media outbox's `MediaResendRequired` path — chunks are re-encoded,
1644 /// never re-sealed.
1645 Deferred,
1646 /// The chunk was refused as illegitimate rather than undeliverable. Two
1647 /// shapes: an unencrypted chunk refused by the encryption policy, and an
1648 /// encrypted chunk that failed its identity binding — the envelope named
1649 /// another pair's session slot, or the MLS credential authenticated a
1650 /// different sender than the wire envelope claims.
1651 ///
1652 /// Like [`InternalMessageResult::SecurityRejected`] for text, the caller
1653 /// must NOT ACK (don't confirm to an injector that the target processes
1654 /// their messages) and must unmark the id (so a replay re-enters this gate
1655 /// instead of the duplicate re-ACK path), matching the plaintext-text
1656 /// rejection in the receive loop.
1657 Rejected,
1658}
1659
1660/// Pending message waiting for session establishment.
1661#[derive(Clone, Serialize, Deserialize)]
1662pub(crate) struct PendingMessage {
1663 /// Original plaintext content.
1664 pub(crate) content: String,
1665 /// Message priority.
1666 pub(crate) priority: MessagePriority,
1667 /// Message ID (preserved from initial creation).
1668 pub(crate) message_id: MessageId,
1669 /// Reply-to message ID if applicable.
1670 pub(crate) reply_to_msg: Option<MessageId>,
1671 /// Forwarding attribution (preserved so it survives the pending queue).
1672 #[serde(default, skip_serializing_if = "Option::is_none")]
1673 pub(crate) forwarded_from: Option<ForwardInfo>,
1674 /// Content type of the original message (preserved for forwarded non-text messages).
1675 #[serde(default)]
1676 pub(crate) content_type: ContentType,
1677 /// Media metadata (preserved for forwarded media messages).
1678 #[serde(default, skip_serializing_if = "Option::is_none")]
1679 pub(crate) media_metadata: Option<MediaMetadata>,
1680 /// Option-borne rich extras from the rich send surface. Kept separate
1681 /// from the legacy `forwarded_from`/`media_metadata` fields above: those
1682 /// flush as outer cleartext (shipped forward behavior), while these must
1683 /// flush inside the sealed rich body or be dropped — never cleartext.
1684 #[serde(default, skip_serializing_if = "Option::is_none")]
1685 pub(crate) rich: Option<RichSendExtras>,
1686 /// When the message *first* entered the pending queue.
1687 ///
1688 /// Drives `pending_message_max_lifetime_ms`, and is deliberately preserved
1689 /// across a re-queue (see [`PendingProvenance`]): a flush that finds the
1690 /// session still unavailable puts the entry back, and stamping a fresh
1691 /// timestamp there would let repeated reconciliation renew an "absolute"
1692 /// lifetime forever.
1693 pub(crate) queued_at: DateTime<Utc>,
1694 /// Serialized footprint of this entry, for the pending-queue byte budgets
1695 /// ([`MAX_PENDING_MESSAGE_BYTES_PER_PEER`] /
1696 /// [`MAX_PENDING_MESSAGE_BYTES_GLOBAL`]).
1697 ///
1698 /// Derived, so it is not persisted; it is (re)computed by
1699 /// [`PendingMessage::measure`] at the two points an entry comes into
1700 /// existence — admission and load-from-storage — and then simply travels
1701 /// with the entry as it is moved between the queue, a flush, and a
1702 /// re-queue.
1703 #[serde(skip)]
1704 pub(crate) serialized_bytes: usize,
1705}
1706
1707/// One persisted pending-queue record: a single queued message plus the peer
1708/// it is queued for.
1709///
1710/// The recipient rides *inside* the record because the key is the message id,
1711/// and the in-memory queue is a map keyed by recipient that has to be rebuilt
1712/// from records read in whatever order the store enumerates them. Same shape as
1713/// [`OutboxEntry`], whose recipient likewise travels in the record (inside its
1714/// `Message`) rather than in the key.
1715///
1716/// Deliberately a wrapper rather than a `recipient` field on [`PendingMessage`]:
1717/// in memory the recipient is already the map key, and a second copy there
1718/// would be free to drift from it.
1719#[derive(Serialize, Deserialize)]
1720pub(crate) struct PendingMessageRecord {
1721 pub(crate) recipient: String,
1722 pub(crate) message: PendingMessage,
1723}
1724
1725/// One inbound encrypted frame parked in the pending-decryption queue, as
1726/// persisted under [`storage_keys::PENDING_DECRYPT_ENTRIES`].
1727///
1728/// The queue itself is in-memory only (`PendingDecryptionQueue`); this record
1729/// is what lets an entry survive an app restart. Without it a frame that
1730/// arrived before its session was ready was lost the moment the process died
1731/// — and with a relay that pushes ciphertext without store-and-forward there
1732/// is no second copy to ask for, so a restart during a slow handshake was a
1733/// silently lost message.
1734///
1735/// Keyed by message id, like [`PendingMessageRecord`], so a drain, a prune or
1736/// an overflow drop deletes exactly the record it settled. `peer_id` rides
1737/// inside the record because the in-memory queue is a per-peer map rebuilt
1738/// from records the store enumerates in no particular order.
1739///
1740/// `first_received_at_ms` is wall-clock, not the `Instant` the in-memory entry
1741/// carries: an `Instant` does not survive a restart, so on restore the entry is
1742/// re-stamped with a fresh `Instant::now()` (its in-memory TTL restarts) and
1743/// this field bounds the *total* time on disk instead —
1744/// `PENDING_DECRYPT_PERSISTED_MAX_AGE_MS`.
1745///
1746/// `version` is a forward-compatibility hinge: a reader that sees a version it
1747/// does not know treats the record as corrupt and drops it rather than
1748/// guessing at fields. Records written before the field existed default to 1.
1749#[derive(Serialize, Deserialize)]
1750pub(crate) struct PendingDecryptRecord {
1751 #[serde(default = "pending_decrypt_record_version")]
1752 pub(crate) version: u8,
1753 pub(crate) peer_id: String,
1754 pub(crate) message: Message,
1755 pub(crate) first_received_at_ms: i64,
1756 #[serde(default)]
1757 pub(crate) received_via: Option<TransportType>,
1758}
1759
1760/// The only record version this build writes or reads.
1761pub(crate) const PENDING_DECRYPT_RECORD_VERSION: u8 = 1;
1762
1763fn pending_decrypt_record_version() -> u8 {
1764 PENDING_DECRYPT_RECORD_VERSION
1765}
1766
1767impl PendingMessage {
1768 /// Recomputes [`Self::serialized_bytes`] from the current field values.
1769 ///
1770 /// Measures what persistence actually costs (the serialized entry) rather
1771 /// than estimating from `content.len()`, so rich extras and forward
1772 /// attribution are counted. A serialization failure — which would also make
1773 /// the entry unpersistable — falls back to the content length so the entry
1774 /// still consumes budget rather than reading as free.
1775 pub(crate) fn measure(&mut self) {
1776 self.serialized_bytes = serde_json::to_vec(self)
1777 .map(|encoded| encoded.len())
1778 .unwrap_or_else(|_| self.content.len());
1779 }
1780}
1781
1782/// Identity carried by an outbound message that is re-entering the
1783/// pending-session queue rather than being queued for the first time.
1784///
1785/// Both fields exist to keep a re-queue from looking like a fresh send: the id
1786/// so `MessageSent`/`MessageDelivered`/`MessageFailed` stay correlatable with
1787/// what `send_message*` returned, and the timestamp so the absolute pending
1788/// lifetime is measured from first entry.
1789#[derive(Debug, Clone)]
1790pub(crate) struct PendingProvenance {
1791 /// The id the caller already holds.
1792 pub(crate) message_id: MessageId,
1793 /// When the message first entered the pending queue, or `None` when the
1794 /// re-queue does not come *from* that queue — the resend re-seal path
1795 /// passes an outbox id, and an entry it (all but unreachably) enqueues is
1796 /// starting its pending lifetime now.
1797 pub(crate) first_queued_at: Option<DateTime<Utc>>,
1798}
1799
1800impl PendingProvenance {
1801 /// Provenance for a message being put back into the pending queue.
1802 pub(crate) fn requeued(message: &PendingMessage) -> Self {
1803 Self {
1804 message_id: message.message_id.clone(),
1805 first_queued_at: Some(message.queued_at),
1806 }
1807 }
1808
1809 /// Provenance for a known id with no pending-queue history.
1810 pub(crate) fn for_id(message_id: MessageId) -> Self {
1811 Self {
1812 message_id,
1813 first_queued_at: None,
1814 }
1815 }
1816}
1817
1818/// Durable state for a peer MLS session.
1819#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1820pub(crate) enum SessionState {
1821 Pending,
1822 Confirmed,
1823}
1824
1825impl SessionState {
1826 pub(crate) fn as_str(&self) -> &'static str {
1827 match self {
1828 Self::Pending => "Pending",
1829 Self::Confirmed => "Confirmed",
1830 }
1831 }
1832}
1833
1834/// Durable lifecycle states for outbound Welcome delivery.
1835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1836pub(crate) enum WelcomeDeliveryState {
1837 Created,
1838 SendAttempted,
1839 Sent,
1840 Failed,
1841 Expired,
1842}
1843
1844impl WelcomeDeliveryState {
1845 pub(crate) fn as_str(&self) -> &'static str {
1846 match self {
1847 Self::Created => "Created",
1848 Self::SendAttempted => "SendAttempted",
1849 Self::Sent => "Sent",
1850 Self::Failed => "Failed",
1851 Self::Expired => "Expired",
1852 }
1853 }
1854}
1855
1856/// Per-peer throttle for presence-driven welcome rescue (see
1857/// `OfflineProtocol::on_peer_presence`). Deliberately in-memory only: a
1858/// restart resets the backoff, and the one free rescue that buys is useful
1859/// after a restart anyway.
1860#[derive(Debug, Clone)]
1861pub(crate) struct PresenceRescueThrottle {
1862 pub(crate) next_allowed_at: DateTime<Utc>,
1863 /// Consecutive rescues without the session confirming; drives the
1864 /// exponential backoff exponent.
1865 pub(crate) rescues: u32,
1866}
1867
1868/// Durable metadata for outbound Welcome reliability handling.
1869#[derive(Debug, Clone, Serialize, Deserialize)]
1870pub(crate) struct WelcomeLifecycleRecord {
1871 pub(crate) peer_id: String,
1872 pub(crate) group_id: String,
1873 pub(crate) state: WelcomeDeliveryState,
1874 pub(crate) attempt: u32,
1875 /// Consecutive `PeerUnreachable` parks (relay `DeliveryError` verdicts)
1876 /// since the last reachability edge; drives the escalating retry
1877 /// interval capped at [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`]. Reset on
1878 /// re-arm (presence online / neighbor discovered). Defaulted for
1879 /// records persisted before the field existed.
1880 #[serde(default)]
1881 pub(crate) unreachable_parks: u32,
1882 pub(crate) welcome_message: Message,
1883 pub(crate) next_retry_at: Option<DateTime<Utc>>,
1884 pub(crate) last_reason_code: Option<crate::events::WelcomeReasonCode>,
1885 pub(crate) last_transport_error: Option<String>,
1886 pub(crate) created_at: DateTime<Utc>,
1887 pub(crate) expires_at: DateTime<Utc>,
1888}
1889
1890/// Storage key types for message persistence.
1891pub(crate) mod storage_keys {
1892 /// Legacy key type for pending encrypted messages, keyed by *recipient*,
1893 /// each record holding that peer's whole queue.
1894 ///
1895 /// Superseded by [`PENDING_MESSAGE_ENTRIES`]. Nothing writes this category
1896 /// any more: `restore_pending_messages` reads it, migrates each queue into
1897 /// the per-message layout, and deletes the record. It is read-only
1898 /// migration scaffolding, and retires on the same trigger as the
1899 /// [`ADOPTABLE_STATE_KEY_TYPES`] sweep — an install that skips the
1900 /// migrating release entirely still needs it.
1901 pub const PENDING_MESSAGES: &str = "pending_messages";
1902 /// Key type for pending encrypted messages, keyed by message id — one
1903 /// record per queued message, like [`OUTBOX`].
1904 ///
1905 /// Keying by id rather than by recipient is what makes a lost record
1906 /// *individually settleable*: the id the application is holding is the key,
1907 /// so a record that will not open still names the message it destroyed. The
1908 /// per-recipient layout could only report the loss per peer, because every
1909 /// id was inside the record that would not open.
1910 pub const PENDING_MESSAGE_ENTRIES: &str = "pending_message_entries";
1911 /// Inbound ciphertext parked in the pending-decryption queue, one record
1912 /// per message keyed by message id — the receive-side mirror of
1913 /// [`PENDING_MESSAGE_ENTRIES`]. See `PendingDecryptRecord`.
1914 pub const PENDING_DECRYPT_ENTRIES: &str = "pending_decrypt_entries";
1915 /// Key type for persisted per-peer MLS session confirmation state.
1916 pub const SESSION_STATES: &str = "session_states";
1917 /// Key type for persisted per-peer received key packages (survives restart).
1918 pub const PEER_KEY_PACKAGES: &str = "peer_key_packages";
1919 /// Key type for persisted per-peer advertised capability versions
1920 /// (env/rich), which must outlive the key package entry above — that one
1921 /// is deleted once a session is established (see
1922 /// [`super::PeerCapabilities`]).
1923 pub const PEER_CAPABILITIES: &str = "peer_capabilities";
1924 /// Key type for persisted per-peer outbound welcome lifecycle state.
1925 pub const WELCOME_LIFECYCLES: &str = "welcome_lifecycles";
1926 /// Key type for persisted store-and-forward outbox entries, keyed by
1927 /// message id. Holds only main-outbox (non-media) entries so undelivered
1928 /// messages survive app restarts; the media (file-chunk) outbox is
1929 /// intentionally excluded because file transfers are not persisted and
1930 /// must be re-initiated by the app after a restart.
1931 pub const OUTBOX: &str = "outbox";
1932 /// Key type for persisted outbound media transfer descriptors, keyed by
1933 /// file id. Descriptor-only (never chunk bytes): a descriptor surviving
1934 /// into a restore marks a transfer the app must re-initiate, surfaced
1935 /// via `MediaResendRequired`.
1936 pub const MEDIA_DESCRIPTORS: &str = "media_descriptors";
1937 /// Key type for the Lamport clock value.
1938 pub const LAMPORT_CLOCK: &str = "lamport_clock";
1939 /// Key ID for the single Lamport clock entry.
1940 pub const LAMPORT_CLOCK_ID: &str = "current";
1941 /// The deduplicator's exact-mode seen set, one record for the whole set.
1942 /// See `DedupSeenRecord`.
1943 pub const DEDUP_SEEN_IDS: &str = "dedup_seen_ids";
1944 /// Key ID for the single seen-set record.
1945 pub const DEDUP_SEEN_IDS_ID: &str = "current";
1946 /// Key type for the durable record that a peer has proved it runs MLS.
1947 ///
1948 /// Successor to the `tofu_keys` category, which stored a pinned public key
1949 /// per peer. The pin is gone — an address *is* its key's hash, so nothing
1950 /// needs storing to check one — but the store had a second job the
1951 /// derivation does not do: it was the half of
1952 /// `OfflineProtocol::encryption_capable_peers` that survived a restart, and
1953 /// therefore what keeps the plaintext-downgrade gate shut for a peer whose
1954 /// session was torn down. That job is all this category still does, so the
1955 /// value is a timestamp and nothing else.
1956 ///
1957 /// A leftover `tofu_keys` record is left where it is, and no migration is
1958 /// written for it. Records from before the identity change are keyed by a
1959 /// username, so no live peer id can ever read them back — they are simply
1960 /// unreachable. Records written in the window *between* the identity
1961 /// change and the pin store's deletion are keyed by an address and would
1962 /// still resolve, so a peer marked capable there with no live session
1963 /// loses that durable mark across the upgrade: it falls back to the
1964 /// `is_session_confirmed` check until the next key-package exchange
1965 /// re-marks it. That is the fail-closed direction and it costs one
1966 /// exchange, which is why migrating them is not worth the code while no
1967 /// fleet is deployed.
1968 pub const ENCRYPTION_CAPABLE_PEERS: &str = "encryption_capable_peers";
1969 /// Key type for persisted blocked user entries.
1970 pub const BLOCKED_USERS: &str = "blocked_users";
1971 /// Key type for the persistent per-install telemetry scrub secret.
1972 pub const SCRUB_SECRET: &str = "scrub_secret";
1973 /// Key ID for the single scrub-secret entry.
1974 pub const SCRUB_SECRET_ID: &str = "current";
1975 /// Key type for the persistent per-install Nostr transport signing secret.
1976 pub const NOSTR_SIGNING_SECRET: &str = "nostr_signing_secret";
1977 /// Key ID for the single Nostr signing-secret entry.
1978 pub const NOSTR_SIGNING_SECRET_ID: &str = "current";
1979 /// Key type for the Nostr receive watermark: the newest event `created_at`
1980 /// (unix seconds) this install has accepted from a relay.
1981 ///
1982 /// Unlike the signing secret this is *protocol-state*, not secure storage:
1983 /// it is a coarse timestamp, not key material, and losing it costs one
1984 /// wider backfill window rather than an identity.
1985 pub const NOSTR_WATERMARK: &str = "nostr_watermark";
1986 /// Key ID for the single Nostr receive-watermark entry.
1987 pub const NOSTR_WATERMARK_ID: &str = "current";
1988 /// Key type for the Nostr key-package publication slot map: which MLS key
1989 /// package currently stands in each published addressable slot.
1990 ///
1991 /// Protocol state rather than secure storage, on the same reasoning as the
1992 /// watermark: the values are slot labels and package ids, both of which are
1993 /// already public in the published record itself. Losing it costs a round
1994 /// of republication under fresh slot ids, not an identity — the stale
1995 /// records left at the old slots expire with their key packages.
1996 pub const NOSTR_KEY_PACKAGE_SLOTS: &str = "nostr_key_package_slots";
1997 /// Key ID for the single Nostr publication-slot entry.
1998 pub const NOSTR_KEY_PACKAGE_SLOTS_ID: &str = "current";
1999 /// Key type for this install's published username discovery claim.
2000 ///
2001 /// Records *which* username was last published, so a profile change or a
2002 /// switched-off feature can retract the previous claim. Without it a
2003 /// renamed install leaves its old name standing in the directory
2004 /// indefinitely, pointing at an address that is still live — the one
2005 /// failure a directory must not have, since retraction is the only control
2006 /// a claimant holds.
2007 ///
2008 /// Protocol state rather than secure storage, on the same reasoning as the
2009 /// slot map: the value is a username that this install already published
2010 /// in a public place. Losing it costs an un-retractable stale claim, which
2011 /// expires only when a resolver's second hop fails.
2012 pub const NOSTR_DISCOVERY_CLAIM: &str = "nostr_discovery_claim";
2013 /// Key ID for the single discovery-claim entry.
2014 pub const NOSTR_DISCOVERY_CLAIM_ID: &str = "current";
2015 /// Key type for the per-install key that seals sensitive protocol-state
2016 /// records at rest.
2017 ///
2018 /// Lives in *secure* storage — it is the one piece of the protocol-state
2019 /// domain that must be credential-backed, because it is what gives the
2020 /// install-scoped container's contents their confidentiality (see
2021 /// [`crate::protocol::state_crypto`]).
2022 pub const STATE_RECORD_KEY: &str = "protocol_state_record_key";
2023 /// Key ID for the single protocol-state record key entry.
2024 pub const STATE_RECORD_KEY_ID: &str = "current";
2025 /// Key type for peers we are the both-create "owner" of and are awaiting a
2026 /// group-aware decrypt from before confirming (see
2027 /// [`crate::protocol::OfflineProtocol`]'s `both_create_awaiting_decrypt`).
2028 /// Persisted so an owner restart mid-convergence cannot let a stale plaintext
2029 /// probe/ack prematurely confirm and strand the peer on a divergent group.
2030 pub const BOTH_CREATE_AWAITING_DECRYPT: &str = "both_create_awaiting_decrypt";
2031 /// Key type for the marker recording that pre-split protocol state has been
2032 /// adopted out of secure storage (see
2033 /// `OfflineProtocol::adopt_legacy_protocol_state`). Lives in *protocol
2034 /// state* storage, so a reinstall — which drops that container — correctly
2035 /// re-runs the sweep against whatever the credential store still holds.
2036 pub const STATE_ADOPTION: &str = "protocol_state_adoption";
2037 /// Key ID for the single state-adoption marker entry.
2038 pub const STATE_ADOPTION_ID: &str = "v1";
2039
2040 /// Key type for compacted replicated documents, one record per document.
2041 ///
2042 /// Key ID is `{space}/{doc}`. The space is validated by
2043 /// `offline_protocol_data::validate_space_name` and the document by
2044 /// `validate_name`; neither charset includes the separator, so the
2045 /// composed key always parses back into the two parts it was built
2046 /// from. (The space charset additionally allows `:`, so a group space
2047 /// can be named by its `group:<uuid>` id — inert here, since keys are
2048 /// parsed by stripping the space prefix and splitting on `/`.)
2049 ///
2050 /// Post-split only: deliberately absent from
2051 /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2052 /// for it.
2053 pub const DATA_DOCS: &str = "data_docs";
2054 /// Key type for the uncompacted commit log of a replicated document.
2055 ///
2056 /// Key ID is `{space}/{doc}/{seq:016x}`, one record per persisted commit
2057 /// batch. Append-only by construction: a commit writes a new record and
2058 /// never rewrites an existing one, which is what keeps flash churn
2059 /// proportional to what changed rather than to document size.
2060 ///
2061 /// Post-split only: deliberately absent from
2062 /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2063 /// for it.
2064 pub const DATA_DELTA_LOG: &str = "data_delta_log";
2065 /// Key type for per-space document indexes and compaction bookkeeping.
2066 ///
2067 /// Key ID is the space id. Sync watermarks join this record when
2068 /// replication lands; today it carries the document list and nothing
2069 /// else. Sequence numbers and sizes are derived from the delta records
2070 /// themselves at open, so this record can never disagree with the store.
2071 ///
2072 /// Post-split only: deliberately absent from
2073 /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2074 /// for it.
2075 pub const DATA_SPACES: &str = "data_spaces";
2076 /// Key type for per-space replication bookkeeping.
2077 ///
2078 /// Key ID is the space id. Holds the containment state the sync path
2079 /// needs to survive a crash: the digest of the blob currently being
2080 /// handed to the document engine, and the digests of blobs that were
2081 /// in flight when a previous run died.
2082 ///
2083 /// Kept out of [`DATA_SPACES`] deliberately. That record is a cache
2084 /// reconciled from `list_keys` at open, so anything in it may be
2085 /// rebuilt from the store at any time; this one records something the
2086 /// store cannot show, and rebuilding it from the listing would silently
2087 /// answer "nothing was in flight" after exactly the crash it exists to
2088 /// remember.
2089 ///
2090 /// Post-split only: deliberately absent from
2091 /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2092 /// for it.
2093 pub const DATA_SYNC: &str = "data_sync";
2094 /// Key type for what a replicated document's name says after a removal.
2095 ///
2096 /// Key ID is `{space}/{doc}`, the same composition [`DATA_DOCS`] uses.
2097 /// The value carries the removal floor (the document's version at the
2098 /// moment it was removed) and whether the name currently holds a
2099 /// document again.
2100 ///
2101 /// A separate category rather than a field on [`DATA_SPACES`] for the
2102 /// reason that record is a cache: it is reconciled from `list_keys` at
2103 /// open, so a removal recorded there would be rebuilt away by exactly
2104 /// the listing that still shows the records a half-finished removal
2105 /// left behind. It is written *before* those records are deleted, so a
2106 /// crash in between leaves a name this category already calls removed,
2107 /// and the next open finishes the job. The reverse order is the
2108 /// resurrection the whole mechanism exists to prevent.
2109 ///
2110 /// An absent record means a live document that has never been removed,
2111 /// so a store written before removals existed reads correctly with no
2112 /// migration.
2113 ///
2114 /// Post-split only: deliberately absent from
2115 /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2116 /// for it.
2117 pub const DATA_DOC_META: &str = "data_doc_meta";
2118
2119 /// Every key type that moved from secure storage into protocol-state
2120 /// storage when the two domains were split.
2121 ///
2122 /// Unlike the MLS key-type set — which is open, because OpenMLS contributes
2123 /// its own labels through `storage_adapter.rs` — this set is closed and
2124 /// declared right here, which is what makes a one-shot bulk adoption
2125 /// possible at all (see `OfflineProtocol::adopt_legacy_protocol_state`).
2126 /// A new protocol-state category added *after* the split must NOT be added
2127 /// here: there is no pre-split data for it to inherit, and listing it would
2128 /// only cost a pointless enumeration of the credential store.
2129 ///
2130 /// # Removal
2131 ///
2132 /// This list, `OfflineProtocol::adopt_legacy_protocol_state`, and the
2133 /// `STATE_ADOPTION` marker are one-shot migration scaffolding for installs
2134 /// upgrading *across* the storage split. They stop doing anything once no
2135 /// supported install can still be running a pre-split build — an install
2136 /// that skips the split release entirely still needs them, so the trigger
2137 /// is not "one release later". Delete them only when the oldest supported
2138 /// upgrade path starts at or after the release that introduced the split,
2139 /// and delete all three together: leaving the marker behind without the
2140 /// sweep would make a later re-introduction silently skip itself.
2141 pub const ADOPTABLE_STATE_KEY_TYPES: &[&str] = &[
2142 BLOCKED_USERS,
2143 OUTBOX,
2144 PENDING_MESSAGES,
2145 SESSION_STATES,
2146 WELCOME_LIFECYCLES,
2147 PEER_KEY_PACKAGES,
2148 PEER_CAPABILITIES,
2149 MEDIA_DESCRIPTORS,
2150 BOTH_CREATE_AWAITING_DECRYPT,
2151 LAMPORT_CLOCK,
2152 ];
2153}
2154
2155/// Protocol state.
2156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2157pub enum ProtocolState {
2158 /// Protocol is not started.
2159 Stopped,
2160 /// Protocol is running.
2161 Running,
2162 /// Protocol is paused (background mode).
2163 Paused,
2164}
2165
2166/// Shared state protected by mutex.
2167pub(crate) struct SharedState {
2168 /// Current protocol state.
2169 pub(crate) state: ProtocolState,
2170
2171 /// Event handlers registered by the application.
2172 pub(crate) event_handlers: Vec<EventCallback>,
2173
2174 /// Received messages queue.
2175 pub(crate) received_messages: VecDeque<Message>,
2176
2177 /// Installed telemetry context. When present, `emit_event` additionally
2178 /// forwards every protocol event to `ctx.sink` as a
2179 /// `TelemetryRecord::Protocol`, with identifier scrubbing applied per
2180 /// `ctx.config`. Set via `OfflineProtocol::install_telemetry_sink`.
2181 pub(crate) telemetry: Option<Arc<TelemetryContext>>,
2182}
2183
2184impl SharedState {
2185 pub(crate) fn new() -> Self {
2186 Self {
2187 state: ProtocolState::Stopped,
2188 event_handlers: Vec::new(),
2189 received_messages: VecDeque::new(),
2190 telemetry: None,
2191 }
2192 }
2193
2194 pub(crate) fn emit_event(&self, event: Event) {
2195 // Legacy `EventCallback` handlers run first and receive the raw
2196 // event. This preserves the pre-telemetry contract — any app that
2197 // relied on `on_event` sees exactly what it used to.
2198 //
2199 // Each handler call is panic-isolated so a faulty handler cannot
2200 // unwind through this method while a `MutexGuard<SharedState>` is
2201 // live in the caller's frame — that would poison the shared-state
2202 // mutex and silently degrade every subsequent SDK operation.
2203 for handler in &self.event_handlers {
2204 let event_for_handler = event.clone();
2205 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2206 handler(event_for_handler);
2207 }));
2208 if result.is_err() {
2209 tracing::error!(
2210 event = ?event,
2211 "EventCallback panicked; continuing. Handlers must not panic — see OfflineProtocol::on_event.",
2212 );
2213 }
2214 }
2215 // Sink fan-out runs after, gated on an installed context. The pipe,
2216 // the one production sink, consumes every event by reference in
2217 // `dispatch_protocol_event` and reads no identifier from it, so it
2218 // never reaches the scrub below. Only a sink that declines that
2219 // borrowed offer does: `scrub_event` hashes its identifier fields per
2220 // the installed config, or with
2221 // `TelemetryConfig::with_scrub_ids(false)` borrows the event as is.
2222 //
2223 // Dispatch goes through `dispatch_record` so a panicking sink is
2224 // caught and logged rather than unwinding through the caller's live
2225 // `MutexGuard<SharedState>` — see the helper's docstring.
2226 if let Some(ctx) = &self.telemetry {
2227 // The gate comes first so a disabled pipe stops the clone too,
2228 // and the borrowed offer comes next so a sink that collects by
2229 // name never pays for an event it drops.
2230 if !ctx.enabled() || dispatch_protocol_event(&ctx.sink, &event) {
2231 return;
2232 }
2233 let scrubbed = scrub_event::scrub_event(&event, &ctx.scrubber);
2234 let record = TelemetryRecord::Protocol(Box::new(scrubbed.into_owned()));
2235 dispatch_record(&ctx.sink, &record);
2236 }
2237 }
2238}
2239
2240/// Helper function to lock a mutex and convert poison errors to protocol errors.
2241pub(crate) fn lock_shared_state(
2242 state: &Arc<Mutex<SharedState>>,
2243) -> std::result::Result<std::sync::MutexGuard<'_, SharedState>, Error> {
2244 state
2245 .lock()
2246 .map_err(|_| Error::Other("Shared state mutex poisoned".to_string()))
2247}
2248
2249/// Provenance kept on an outbox entry so an encrypted DM can be *re-sealed*
2250/// against the peer's current MLS session on each resend, instead of replaying
2251/// the ciphertext bytes sealed at first send (which become permanently
2252/// undecryptable once the peer re-keys to a new epoch). Mirrors the fields the
2253/// pre-send [`PendingMessage`] carries, so both re-seal through the same
2254/// `prepare_outbound_content` chokepoint. Only populated for main-outbox
2255/// (non-media) encrypted DMs; absent (`None`) means verbatim replay — the
2256/// fallback for plaintext sends and media chunks.
2257///
2258/// **Memory-only by design.** This holds the message *plaintext*, so it is never
2259/// persisted (see the `#[serde(skip)]` on [`OutboxEntry::reseal`]): persisting it
2260/// would broaden plaintext-at-rest to every sent-but-unACKed encrypted DM for the
2261/// full outbox lifetime, weakening forward secrecy in exchange for only a narrow
2262/// cross-restart reseal benefit. After a restart the restored entry replays
2263/// verbatim; if that resend hits a desync, Tier 1 (un-ACK + re-key) still keeps
2264/// delivery honest rather than silently losing the message.
2265#[derive(Debug, Clone)]
2266pub(crate) struct OutboxReseal {
2267 /// Original plaintext content.
2268 pub(crate) content: String,
2269 /// Message priority.
2270 pub(crate) priority: MessagePriority,
2271 /// Reply-to message ID if applicable.
2272 pub(crate) reply_to_msg: Option<MessageId>,
2273 /// Forwarding attribution.
2274 pub(crate) forwarded_from: Option<ForwardInfo>,
2275 /// Content type of the original message.
2276 pub(crate) content_type: ContentType,
2277 /// Media metadata (cleartext-outer fallback provenance).
2278 pub(crate) media_metadata: Option<MediaMetadata>,
2279 /// Sealed-only rich extras (reply context, rich media metadata, forward
2280 /// info) — re-evaluated against current capability at re-seal time.
2281 pub(crate) rich: Option<RichSendExtras>,
2282}
2283
2284#[derive(Debug, Clone, Serialize, Deserialize)]
2285pub(crate) struct OutboxEntry {
2286 pub(crate) message: Message,
2287 pub(crate) attempt_count: u32,
2288 pub(crate) first_sent_at: DateTime<Utc>,
2289 pub(crate) last_sent_at: DateTime<Utc>,
2290 pub(crate) last_transport: Option<TransportType>,
2291 /// Re-seal provenance; `None` for verbatim-replay entries (plaintext or
2292 /// media). **Memory-only** (`#[serde(skip)]`): it carries the message
2293 /// plaintext, which must never be persisted (see [`OutboxReseal`]). A
2294 /// restored entry deserializes with `reseal: None` and therefore replays
2295 /// verbatim.
2296 #[serde(skip)]
2297 pub(crate) reseal: Option<OutboxReseal>,
2298 /// The relay has handed this frame to a device push at least once
2299 /// (`MessageSent { pushed: true }`, see [`SEND_FAIL_REASON_RELAY_PUSHED`]).
2300 ///
2301 /// Sticky for the entry's lifetime, and persisted, because of what a later
2302 /// relay verdict for the same id means. The relay remembers which
2303 /// `(sender, recipient, message_id)` triples it has pushed and answers a
2304 /// retry of one with `DeliveryError` (`already_pushed`) instead of a
2305 /// second notification, and the bridges cannot tell that verdict from a
2306 /// plain "recipient offline": both reach the core as
2307 /// `recipient_unreachable`. So the reachability probe a park schedules
2308 /// earns a `DeliveryError` fifteen seconds after every push, and without
2309 /// this flag the app would be told `MessageUndeliverable` about a message
2310 /// the push may already have delivered. With it, that verdict still
2311 /// re-parks the entry (the recipient is not on the relay) but emits
2312 /// nothing app-facing. A legacy record restores as `false`, so the first
2313 /// probe after upgrading emits once; nothing is lost.
2314 #[serde(default)]
2315 pub(crate) relay_pushed: bool,
2316}
2317
2318#[derive(Clone)]
2319pub(crate) struct PendingMediaMetadataEntry {
2320 pub(crate) content_type: ContentType,
2321 pub(crate) media_metadata: Option<MediaMetadata>,
2322 pub(crate) last_updated_at: Instant,
2323 /// Sender of the file transfer (used to drain partial transfers on block).
2324 pub(crate) sender: String,
2325 /// Rich extras from the sealed chunk-0 plaintext. Never populated from
2326 /// wire (legacy plaintext) chunks — the sealed body is the only trusted
2327 /// carrier.
2328 pub(crate) rich_extras: Option<crate::media_envelope::MediaRichExtras>,
2329 /// The chunk-0 outer `Message` timestamp (wall-clock ms) — the sender's
2330 /// send time, surfaced on `FileReceived` for display ordering.
2331 pub(crate) timestamp_ms: i64,
2332 /// Set when the sealed chunk-0 plaintext said this transfer belongs to
2333 /// the replicated-document layer. Its presence is what keeps every
2334 /// file-facing event of this transfer from firing: progress, completion
2335 /// and failure alike are the data layer's business, not a person's.
2336 ///
2337 /// Never populated from a legacy plaintext chunk. The field lives inside
2338 /// the sealed body precisely so an unauthenticated chunk cannot claim it.
2339 pub(crate) data_purpose: Option<crate::media_envelope::DataPurpose>,
2340}
2341
2342#[derive(Clone)]
2343pub(crate) struct OutboundMediaTransfer {
2344 pub(crate) content_type: ContentType,
2345 pub(crate) recipient: String,
2346 pub(crate) pinned_transport: TransportType,
2347 pub(crate) total_chunks: u32,
2348 pub(crate) delivered_chunks: HashSet<u32>,
2349 pub(crate) last_updated_at: Instant,
2350 pub(crate) media_metadata: Option<MediaMetadata>,
2351 /// Rich extras sealed into chunk 0 (already capability-gated at the
2352 /// `send_media_with` boundary). Kept on the transfer because chunk
2353 /// batches are (re-)encoded via `pump_media_transfers` too.
2354 pub(crate) rich_extras: Option<crate::media_envelope::MediaRichExtras>,
2355 /// Set when this SDK started the transfer for the replicated-document
2356 /// layer rather than on an application's behalf, and kept here for the
2357 /// same reason as the extras above: `pump_media_transfers` re-encodes
2358 /// later batches and has only the transfer to read from.
2359 ///
2360 /// No public send surface can produce one.
2361 pub(crate) data_purpose: Option<crate::media_envelope::DataPurpose>,
2362}
2363
2364/// Crash-scoped descriptor of an in-flight outbound media transfer.
2365///
2366/// Persisted (no chunk bytes — see commit 42d1b86's rationale: resurrected
2367/// chunks can never complete, and per-chunk secure-storage writes are
2368/// expensive) when a transfer starts and deleted whenever the in-memory
2369/// transfer is removed (completed, aborted, or stale-swept). A descriptor
2370/// found on restore therefore means the app died mid-transfer:
2371/// [`crate::events::Event::MediaResendRequired`] is emitted so the app can
2372/// re-supply the bytes via `send_media_with` under the same `file_id`,
2373/// validated against `file_checksum`.
2374#[derive(Clone, Serialize, Deserialize)]
2375pub(crate) struct MediaTransferDescriptor {
2376 pub(crate) file_id: String,
2377 pub(crate) recipient: String,
2378 pub(crate) file_name: String,
2379 pub(crate) file_size: u64,
2380 /// SHA-256 hex of the plaintext file bytes (same value every chunk
2381 /// carries as `FileChunk::file_checksum`).
2382 pub(crate) file_checksum: String,
2383 pub(crate) content_type: ContentType,
2384 /// Wall-clock start of the transfer; restore prunes by
2385 /// `outbox_max_lifetime_ms` age.
2386 pub(crate) queued_at: DateTime<Utc>,
2387 /// Set when the transfer belonged to the replicated-document layer.
2388 ///
2389 /// Persisted for one reason: so restore can tell these apart and NOT ask
2390 /// an application to re-supply them. There is no public API that could
2391 /// answer such a request without stripping the purpose, and a resend
2392 /// with the purpose stripped is a document snapshot delivered to the
2393 /// peer's user as a downloaded file, which is the exact outcome the
2394 /// capability gate and the envelope version exist to prevent, reached
2395 /// through this SDK's own recovery path.
2396 ///
2397 /// Serde-defaulted, so a descriptor written before this field existed
2398 /// still parses and reads as an ordinary transfer, which is what it was.
2399 #[serde(default, skip_serializing_if = "Option::is_none")]
2400 pub(crate) data_purpose: Option<crate::media_envelope::DataPurpose>,
2401}
2402
2403pub(crate) enum OutboundSendPreparation {
2404 Ready(String),
2405 Queued(MessageId),
2406}
2407
2408/// Pins that every signing domain, live or reserved, is mutually non-prefixing.
2409///
2410/// # Why this test exists here, of all places
2411///
2412/// Four domains are in production and they live in three languages and two
2413/// repositories: `offline-ctrl-v1` (this crate), `offline-disc-v1` and
2414/// `offline-invite-v1` (the MLS crate), and `offline-relay-addr-v1` (the relay
2415/// server, plus hand-mirrored copies in the iOS and Android bridges). Nothing
2416/// can import all four, so this module is the one place they can be compared at
2417/// all: it is the highest crate that can see two of them, and the fourth is
2418/// pinned as a literal.
2419///
2420/// A fifth is reserved and covered here too, for the reason reservation exists:
2421/// non-prefixing is a property of the whole set, so a domain that is specified
2422/// but not yet emitted still has to be held against every other one. A guard
2423/// that watched only live domains would happily accept a new live domain that
2424/// prefixes a reserved one, and the collision would be discovered by the
2425/// implementation that finally emits it.
2426#[cfg(test)]
2427mod signing_domain_tests {
2428 use offline_protocol_mls::discovery::DISCOVERY_SIGN_DOMAIN;
2429 use offline_protocol_mls::invite::INVITE_SIGN_DOMAIN;
2430
2431 use offline_protocol_sealed::{CTRL_SIGN_DOMAIN, CTRL_SIGN_DOMAIN_V2};
2432
2433 /// The relay's address-proof domain.
2434 ///
2435 /// A literal because it is defined in the relay-server repository and
2436 /// hand-mirrored in `AddressDeclarationPolicy.swift` and
2437 /// `AddressDeclarationPolicy.kt` — there is no Rust constant in this
2438 /// workspace to import. If it ever changes there, this test does not
2439 /// notice, which is the accepted cost of a cross-repository constant and is
2440 /// why it is spelled out with this comment attached.
2441 const RELAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-relay-addr-v1";
2442
2443 /// The gateway's address-proof domain: **reserved, not yet emitted.**
2444 ///
2445 /// Specified by `docs/spec/gateway-contract.md` and registered in the
2446 /// signing-domain table of `docs/spec/username-discovery.md`. No code signs
2447 /// under it yet, which is precisely why it is pinned before anything does:
2448 /// it must be chosen against the whole set, and it must stay distinct from
2449 /// [`RELAY_ADDR_SIGN_DOMAIN`] so a proof harvested by a hostile gateway
2450 /// cannot be replayed against the relay, or the reverse.
2451 const GATEWAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-gateway-addr-v1";
2452
2453 /// No domain, live or reserved, may be a prefix of another.
2454 ///
2455 /// The canonical payload is `domain ‖ Σ(u32be(len) ‖ field)`, and the
2456 /// **domain itself is not length-prefixed**. So if one domain were a prefix
2457 /// of another, a signature made under the shorter domain could be replayed
2458 /// as one made under the longer: an attacker chooses a first field whose
2459 /// leading bytes supply the rest of the longer domain, and the two payloads
2460 /// become byte-identical. Length-prefixing the fields does not prevent it,
2461 /// because the collision happens before the first length prefix.
2462 ///
2463 /// The concrete damage this stops is recorded in the addressing work: a
2464 /// hostile relay that could make an address-proof signature verify as a
2465 /// control-frame signature would harvest a replayable control frame from
2466 /// every device that ever authenticated to it.
2467 #[test]
2468 fn signing_domains_are_mutually_non_prefixing() {
2469 let domains: [(&str, &[u8]); 6] = [
2470 ("offline-ctrl-v1", CTRL_SIGN_DOMAIN),
2471 ("offline-ctrl-v2", CTRL_SIGN_DOMAIN_V2),
2472 ("offline-disc-v1", DISCOVERY_SIGN_DOMAIN),
2473 ("offline-invite-v1", INVITE_SIGN_DOMAIN),
2474 ("offline-relay-addr-v1", RELAY_ADDR_SIGN_DOMAIN),
2475 ("offline-gateway-addr-v1", GATEWAY_ADDR_SIGN_DOMAIN),
2476 ];
2477
2478 for (a_name, a) in domains {
2479 for (b_name, b) in domains {
2480 if a_name == b_name {
2481 continue;
2482 }
2483 // Both the constant's name and its *value* are reported: a
2484 // failure is usually caused by an edited value, and a message
2485 // naming only the constant sends the reader to the wrong place.
2486 assert!(
2487 !a.starts_with(b),
2488 "signing domain {} ({:?}) is a prefix of {} ({:?}), which \
2489 lets a signature made under one verify under the other",
2490 b_name,
2491 String::from_utf8_lossy(b),
2492 a_name,
2493 String::from_utf8_lossy(a)
2494 );
2495 }
2496 }
2497 }
2498
2499 /// The literals are what the rest of the system, and every other
2500 /// implementation, actually expects. A renamed constant that still passes
2501 /// the non-prefix test above would silently invalidate every signature in
2502 /// the field, so the spellings are pinned too.
2503 #[test]
2504 fn signing_domains_have_their_published_spellings() {
2505 assert_eq!(CTRL_SIGN_DOMAIN, b"offline-ctrl-v1");
2506 assert_eq!(CTRL_SIGN_DOMAIN_V2, b"offline-ctrl-v2");
2507 assert_eq!(DISCOVERY_SIGN_DOMAIN, b"offline-disc-v1");
2508 assert_eq!(INVITE_SIGN_DOMAIN, b"offline-invite-v1");
2509 assert_eq!(RELAY_ADDR_SIGN_DOMAIN, b"offline-relay-addr-v1");
2510 assert_eq!(GATEWAY_ADDR_SIGN_DOMAIN, b"offline-gateway-addr-v1");
2511 }
2512
2513 /// All six must be distinct, which non-prefixing already implies for
2514 /// unequal strings but not for equal ones: two identical domains are
2515 /// prefixes of each other, and the loop above skips same-name pairs.
2516 #[test]
2517 fn signing_domains_are_distinct() {
2518 let domains: [&[u8]; 6] = [
2519 CTRL_SIGN_DOMAIN,
2520 CTRL_SIGN_DOMAIN_V2,
2521 DISCOVERY_SIGN_DOMAIN,
2522 INVITE_SIGN_DOMAIN,
2523 RELAY_ADDR_SIGN_DOMAIN,
2524 GATEWAY_ADDR_SIGN_DOMAIN,
2525 ];
2526 for (i, a) in domains.iter().enumerate() {
2527 for b in domains.iter().skip(i + 1) {
2528 assert_ne!(a, b, "two signing domains are the same string");
2529 }
2530 }
2531 }
2532}
2533
2534#[cfg(test)]
2535mod send_failure_classification_tests {
2536 use super::*;
2537
2538 /// Every token either producer can mint classifies to itself.
2539 ///
2540 /// This is what lets the two functions compose. A value written by
2541 /// `send_failure_token` is later re-read from a persisted welcome
2542 /// lifecycle and passed through `classify_transport_send_error`; without
2543 /// idempotence that round trip would degrade a precise token to the
2544 /// generic fallback on every emit.
2545 #[test]
2546 fn every_send_failure_token_classifies_to_itself() {
2547 for token in SEND_FAIL_REASON_TOKENS {
2548 assert_eq!(
2549 classify_transport_send_error(token),
2550 *token,
2551 "{token} must survive a re-classification"
2552 );
2553 }
2554 }
2555
2556 /// The vocabulary table really is the vocabulary.
2557 ///
2558 /// `classify_transport_send_error` answers from the table, so it cannot
2559 /// disagree with it — but `send_failure_token` matches independently, and a
2560 /// token added there and forgotten here would classify to the fallback on
2561 /// the next round trip. Walking the transport codes catches that for the
2562 /// arm most likely to grow.
2563 #[test]
2564 fn transport_error_tokens_are_in_the_vocabulary() {
2565 use offline_protocol_transport::Error as T;
2566 let cases = [
2567 T::TransportNotAvailable("ble".into()),
2568 T::PeerNotReachable("bob".into()),
2569 T::SendFailed("x".into()),
2570 T::ReceiveFailed("x".into()),
2571 T::ConfigurationError("x".into()),
2572 T::SerializationError("x".into()),
2573 T::MessageTooLarge(2, 1),
2574 T::CryptoError("x".into()),
2575 T::Other("x".into()),
2576 ];
2577 for case in cases {
2578 let token = send_failure_token(&crate::Error::Transport(case.clone()));
2579 assert!(
2580 SEND_FAIL_REASON_TOKENS.contains(&token),
2581 "{case:?} produced {token}, which is not in SEND_FAIL_REASON_TOKENS"
2582 );
2583 }
2584 }
2585
2586 /// A relay's prose is dropped and its token kept.
2587 ///
2588 /// The prefix is a cross-layer contract the bridges hardcode and core
2589 /// prefix-matches to park a DM, so it must survive verbatim; everything
2590 /// the relay appended to it must not.
2591 #[test]
2592 fn relay_prose_is_dropped_and_the_token_kept() {
2593 assert_eq!(
2594 classify_transport_send_error(
2595 "recipient_unreachable: Recipient is offline and push notification could not be sent"
2596 ),
2597 SEND_FAIL_REASON_RECIPIENT_UNREACHABLE
2598 );
2599 // The relay's real vocabulary, all of it, collapses to one token.
2600 for prose in [
2601 "Recipient connection lost and push notification could not be sent",
2602 "Push notification already sent for this message; recipient unreachable",
2603 "Recipient unreachable and the message is too large to deliver by push",
2604 "Group delivery did not settle for this recipient in time",
2605 ] {
2606 assert_eq!(
2607 classify_transport_send_error(&format!("recipient_unreachable: {prose}")),
2608 SEND_FAIL_REASON_RECIPIENT_UNREACHABLE
2609 );
2610 }
2611 }
2612
2613 /// The relay-pushed answer is its own token, not a `recipient_unreachable`
2614 /// tail. The bridges pass this literal (pinned on their side by
2615 /// `react_native_relay_parks_a_pushed_message_sent`), and reading it under
2616 /// the unreachable prefix would fast-fail connection requests and fail
2617 /// Welcomes that the push may have delivered.
2618 #[test]
2619 fn relay_pushed_is_its_own_token() {
2620 assert_eq!(SEND_FAIL_REASON_RELAY_PUSHED, "relay_pushed");
2621 assert!(!SEND_FAIL_REASON_RELAY_PUSHED.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE));
2622 assert_eq!(
2623 classify_transport_send_error("relay_pushed"),
2624 SEND_FAIL_REASON_RELAY_PUSHED
2625 );
2626 }
2627
2628 /// The mailbox tokens are their own too, for the same reason: neither may
2629 /// be read under the `recipient_unreachable` prefix, and each must
2630 /// classify to itself so a persisted record survives a round trip.
2631 #[test]
2632 fn stored_answers_are_their_own_tokens() {
2633 assert_eq!(SEND_FAIL_REASON_RELAY_STORED, "relay_stored");
2634 assert_eq!(SEND_FAIL_REASON_RELAY_PUSHED_STORED, "relay_pushed_stored");
2635 for token in [
2636 SEND_FAIL_REASON_RELAY_STORED,
2637 SEND_FAIL_REASON_RELAY_PUSHED_STORED,
2638 ] {
2639 assert!(!token.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE));
2640 assert_eq!(classify_transport_send_error(token), token);
2641 }
2642 // `relay_pushed_stored` must not be read as `relay_pushed` with a
2643 // tail: classification is exact for these, so the longer token keeps
2644 // its own meaning rather than degrading to the shorter one.
2645 assert_ne!(
2646 classify_transport_send_error(SEND_FAIL_REASON_RELAY_PUSHED_STORED),
2647 SEND_FAIL_REASON_RELAY_PUSHED
2648 );
2649 }
2650
2651 /// A push is parked in the core but is not a failed send, so the reports
2652 /// that must stay out of a carrier's failure accounting are the two exact
2653 /// push tokens. Anything else, including relay text that merely contains
2654 /// one, still counts — and `relay_stored` counts, because it answers a
2655 /// socket write that really did miss.
2656 #[test]
2657 fn only_a_relay_push_report_is_kept_out_of_carrier_failures() {
2658 use crate::OfflineProtocol;
2659 for reason in ["relay_pushed", "relay_pushed_stored"] {
2660 assert!(
2661 !OfflineProtocol::send_report_is_carrier_failure(Some(reason)),
2662 "{reason} is a push, not a failed send"
2663 );
2664 }
2665 for reason in [
2666 Some("recipient_unreachable: Recipient is offline"),
2667 Some("Internet transport send failed"),
2668 Some("relay_pushed: extra"),
2669 Some("relay_stored"),
2670 None,
2671 ] {
2672 assert!(
2673 OfflineProtocol::send_report_is_carrier_failure(reason),
2674 "{reason:?} is a send failure"
2675 );
2676 }
2677 }
2678
2679 /// Anything unrecognized fails closed to the fallback, never to itself.
2680 #[test]
2681 fn unknown_text_falls_back_and_is_never_echoed() {
2682 for raw in [
2683 "Relay rejected event: blocked",
2684 "off1qyvh9kjj4vy8f943a22qvxsct5s9ydew35v2dl2c is not connected",
2685 "",
2686 ] {
2687 let classified = classify_transport_send_error(raw);
2688 assert_eq!(classified, SEND_FAIL_REASON_TRANSPORT);
2689 assert!(
2690 SEND_FAIL_REASON_TOKENS.contains(&classified),
2691 "the fallback must itself be a token"
2692 );
2693 }
2694 }
2695
2696 /// The literals the UniFFI layer substitutes are classified too, so the
2697 /// vocabulary has exactly one source rather than two that drift.
2698 #[test]
2699 fn uniffi_substituted_literals_are_classified() {
2700 for literal in [
2701 "Internet transport send failed",
2702 "Reticulum transport send failed",
2703 "Nostr transport send failed",
2704 ] {
2705 assert_eq!(
2706 classify_transport_send_error(literal),
2707 SEND_FAIL_REASON_TRANSPORT
2708 );
2709 }
2710 assert_eq!(
2711 classify_transport_send_error("Welcome send confirmation timed out"),
2712 SEND_FAIL_REASON_CONFIRM_TIMEOUT
2713 );
2714 }
2715}
2716
2717/// Holds the spec's conformance-vector index against the files on disk.
2718///
2719/// # Why this test exists here, of all places
2720///
2721/// `docs/spec/README.md` carries a table naming every vector file and the
2722/// chapter it pins, and that table is what a second implementer reads to find
2723/// out what they can test themselves against. It is maintained by hand and
2724/// nothing compared it to the tree, so it was correct only by attention.
2725///
2726/// The failure is quiet in both directions. A vector file added without a row
2727/// is a conformance surface nobody outside this repository knows exists. A row
2728/// left behind by a moved or renamed file sends an implementer looking for
2729/// something that is not there, and the reasonable conclusion is that the
2730/// protocol has no vectors for that chapter rather than that the index is
2731/// stale.
2732///
2733/// It lives beside the signing-domain guard for the same reason that one is
2734/// here: this is the cross-cutting registry module, and the check needs no
2735/// crate's code at all, only the repository tree.
2736#[cfg(test)]
2737mod spec_vector_index_tests {
2738 use std::path::{Path, PathBuf};
2739
2740 fn repo() -> PathBuf {
2741 Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
2742 }
2743
2744 /// The one vector file that is not a conformance surface for a chapter of
2745 /// this specification.
2746 ///
2747 /// The NIP-44 vectors are the upstream specification's own, vendored for
2748 /// the Nostr carrier's sealing and pinned to the checksum that
2749 /// specification publishes. `spec/README.md` lists them anyway, with that
2750 /// stated in place of a chapter, because a reader who finds the file in the
2751 /// tree needs to know why it is different. `spec/conformance.md` does not,
2752 /// because its table is the set of surfaces this specification defines, and
2753 /// a row for someone else's vectors would make that table mean something
2754 /// looser.
2755 ///
2756 /// Named here rather than left as a silent one-way check, because an
2757 /// exemption a test does not state is indistinguishable from a test that
2758 /// was never finished.
2759 const NOT_A_CHAPTER_SURFACE: &str = "nip44.vectors.json";
2760
2761 /// The two documents that index the vectors, and what each is not required
2762 /// to name.
2763 ///
2764 /// `spec/README.md` is the table of contents a reader lands on;
2765 /// `spec/conformance.md` repeats the list with the direction each file
2766 /// pins. The duplication is deliberate, because a conformance chapter that
2767 /// sent the reader elsewhere for the list of what to test against would be
2768 /// missing its own subject. Both are checked in both directions, so the
2769 /// duplication cannot become a disagreement.
2770 const INDEXES: [(&str, &[&str]); 2] = [
2771 ("docs/spec/README.md", &[]),
2772 ("docs/spec/conformance.md", &[NOT_A_CHAPTER_SURFACE]),
2773 ];
2774
2775 /// One index document, or `None` where the repo tree is absent:
2776 /// `cargo package` cannot carry `docs/`, so a published crate's tests must
2777 /// still build.
2778 fn index(name: &str) -> Option<String> {
2779 std::fs::read_to_string(repo().join(name)).ok().or_else(|| {
2780 eprintln!("spec tree not present, skipping the vector index checks");
2781 None
2782 })
2783 }
2784
2785 /// Every vector file either index names exists.
2786 #[test]
2787 fn every_indexed_vector_file_exists() {
2788 for (doc, _) in INDEXES {
2789 let Some(text) = index(doc) else { return };
2790
2791 let mut named = 0;
2792 for token in text.split('`') {
2793 if token.ends_with(".vectors.json") && token.starts_with("crates/") {
2794 named += 1;
2795 assert!(
2796 repo().join(token).is_file(),
2797 "{doc} names {token}, which is not in the tree"
2798 );
2799 }
2800 }
2801 assert!(
2802 named >= 8,
2803 "the scan of {doc} found only {named} vector files, so it is no \
2804 longer reading the table and would pass against anything"
2805 );
2806 }
2807 }
2808
2809 /// Every vector file in the tree is named by the index.
2810 ///
2811 /// This is the direction that catches a surface shipped without being
2812 /// published, which is the one a reviewer cannot see: the tests pass, the
2813 /// file is real, and only an outside implementer ever notices it is
2814 /// undocumented.
2815 #[test]
2816 fn every_vector_file_in_the_tree_is_indexed() {
2817 let mut found = Vec::new();
2818 for crate_dir in std::fs::read_dir(repo().join("crates")).expect("crates/") {
2819 let data = crate_dir.expect("an entry").path().join("tests/data");
2820 let Ok(entries) = std::fs::read_dir(&data) else {
2821 continue;
2822 };
2823 for entry in entries {
2824 let path = entry.expect("an entry").path();
2825 let name = path
2826 .file_name()
2827 .expect("a name")
2828 .to_string_lossy()
2829 .to_string();
2830 if name.ends_with(".vectors.json") {
2831 // Compared against a path written in a markdown table, so
2832 // the separator has to be the one the document uses, not
2833 // the one this platform builds. `Path::join` yields
2834 // backslashes on Windows and the table is all forward
2835 // slashes, so without the replace, every comparison below
2836 // fails there and passes everywhere else.
2837 //
2838 // Falling back to the bare file name if the prefix will not
2839 // strip would be worse than failing: the table holds full
2840 // paths that end in that name, so `contains` would match on
2841 // any of them and the check would quietly degrade to
2842 // asserting the file is mentioned somewhere.
2843 let relative = path.strip_prefix(repo()).unwrap_or_else(|_| {
2844 panic!("{} is not under the repo root", path.display())
2845 });
2846 found.push(relative.to_string_lossy().replace('\\', "/"));
2847 }
2848 }
2849 }
2850
2851 assert!(
2852 !found.is_empty(),
2853 "no vector files were found, so this test proves nothing"
2854 );
2855
2856 for (doc, exempt) in INDEXES {
2857 let Some(text) = index(doc) else { return };
2858 for path in &found {
2859 if exempt.iter().any(|name| path.ends_with(name)) {
2860 continue;
2861 }
2862 assert!(
2863 text.contains(path),
2864 "{path} is a conformance surface that {doc} does not list, \
2865 so nobody outside this repository knows it exists"
2866 );
2867 }
2868 }
2869 }
2870}