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