Skip to main content

offline_protocol/
config.rs

1//! Protocol configuration.
2
3use crate::constants::DEFAULT_INITIAL_TTL;
4use crate::protocol::mesh_relay::MeshRelayConfig;
5use offline_protocol_reliability::{AckConfig, DeduplicatorConfig, RetryConfig};
6use offline_protocol_router::{DorsConfig, PathConfig, RelayConfig};
7use serde::{Deserialize, Serialize};
8
9/// Overflow policy for bounded pending encrypted message queues.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum OverflowPolicy {
13    /// Evict the oldest queued message to make room for the new message.
14    #[default]
15    DropOldest,
16    /// Drop the newly received message when capacity is reached.
17    DropNewest,
18}
19
20/// Configuration for inbound encrypted messages received before session readiness.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PendingQueueConfig {
23    /// Maximum number of pending encrypted messages per peer.
24    pub max_pending_per_peer: usize,
25    /// Maximum number of pending encrypted messages across all peers.
26    pub max_pending_global: usize,
27    /// Maximum total payload bytes (content plus binary content) queued per
28    /// peer.
29    ///
30    /// Encrypted media chunks are queued whole while the sender's session is
31    /// not yet ready, so the count limits alone would admit up to
32    /// `max_pending_per_peer` × chunk-size bytes of ciphertext. The default
33    /// (4 MB) covers the largest legitimate pre-session backlog: 2 concurrent
34    /// transfers × 8 in-flight chunks × 256 KB internet chunks.
35    #[serde(default = "default_max_pending_bytes_per_peer")]
36    pub max_pending_bytes_per_peer: usize,
37    /// Maximum total payload bytes queued across all peers. The default
38    /// (32 MB) bounds worst-case queue memory on mobile devices regardless of
39    /// how many peers are mid-establishment.
40    #[serde(default = "default_max_pending_bytes_global")]
41    pub max_pending_bytes_global: usize,
42    /// Time-to-live for pending encrypted messages in milliseconds.
43    pub pending_ttl_ms: u64,
44    /// Overflow policy when queue limits are reached.
45    pub overflow_policy: OverflowPolicy,
46}
47
48/// Default TTL for the pending-decryption queue (30 minutes, in ms).
49///
50/// Under the deferred-ACK model an undecryptable message is no longer ACKed on
51/// receipt, so this queue is the primary recovery window before the session
52/// confirms — a 2-minute window was too short for a peer whose Welcome is slow
53/// to arrive/adopt. Memory stays bounded by the per-peer/global byte caps plus
54/// the `DropOldest` overflow policy; a longer TTL only lets entries linger
55/// within those caps, it does not raise the ceiling.
56///
57/// The UniFFI `PendingQueueConfig` default mirrors this value — reference this
58/// constant there rather than re-hardcoding it.
59pub const DEFAULT_PENDING_TTL_MS: u64 = 1_800_000;
60
61fn default_max_pending_bytes_per_peer() -> usize {
62    4 * 1024 * 1024
63}
64
65fn default_max_pending_bytes_global() -> usize {
66    32 * 1024 * 1024
67}
68
69impl Default for PendingQueueConfig {
70    fn default() -> Self {
71        Self {
72            max_pending_per_peer: 64,
73            max_pending_global: 4096,
74            max_pending_bytes_per_peer: default_max_pending_bytes_per_peer(),
75            max_pending_bytes_global: default_max_pending_bytes_global(),
76            // 30 minutes; see DEFAULT_PENDING_TTL_MS for the deferred-ACK
77            // rationale and the FFI-mirror contract.
78            pending_ttl_ms: DEFAULT_PENDING_TTL_MS,
79            overflow_policy: OverflowPolicy::DropOldest,
80        }
81    }
82}
83
84/// Encryption configuration for automatic MLS handling.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct EncryptionConfig {
87    /// Whether automatic encryption is enabled.
88    /// When enabled, messages are automatically encrypted/decrypted using MLS.
89    pub enabled: bool,
90
91    /// Auto-exchange key packages on peer discovery.
92    /// When enabled, key packages are automatically sent when neighbors are discovered.
93    pub auto_key_exchange: bool,
94
95    /// Store pending messages when no session exists.
96    /// Messages will be sent automatically after the session is established.
97    pub store_pending: bool,
98
99    /// Require encryption for messages (default: `true`).
100    ///
101    /// When enabled, sends fail closed if encryption cannot be applied:
102    /// with MLS uninitialized every send returns [`crate::Error::EncryptFailed`],
103    /// and with no confirmed session messages are queued (when
104    /// `store_pending` is set) or fail with
105    /// [`crate::Error::SessionNotReady`] instead of ever leaving as
106    /// plaintext. Inbound plaintext content — text messages and legacy
107    /// media alike — is rejected without being surfaced to the app
108    /// (plaintext carries no sender authentication, so anyone could
109    /// inject it under a contact's name); each rejection emits a
110    /// [`crate::events::SecurityWarningCode::PlaintextReceiveRejected`]
111    /// warning, once per peer. Even under the opt-out, inbound plaintext
112    /// from a peer *known to run MLS* is rejected as a downgrade/forgery
113    /// attempt — see `OfflineProtocol::encryption_capable_peers`. A
114    /// confirmed session is not required, and deliberately so: an honest
115    /// peer queues rather than downgrading while a session is pending, so
116    /// cleartext from a peer we know speaks MLS is never legitimate.
117    /// Peers that have shown no MLS signal stay readable, which is what
118    /// makes this opt-out usable for legacy interop.
119    ///
120    /// Disable only for deployments that deliberately send cleartext
121    /// (e.g. open broadcast/mesh bootstrap without provisioned MLS
122    /// storage) — set this to `false` explicitly, or use
123    /// [`EncryptionConfig::disabled`] to turn encryption off entirely.
124    /// Every plaintext send then emits a
125    /// [`crate::events::SecurityWarningCode::PlaintextSend`] warning
126    /// (once per peer). Internal control messages (key exchange, ACKs,
127    /// service discovery) are exempt and unaffected.
128    pub require_encryption: bool,
129
130    /// Bounds and eviction policy for pending inbound encrypted messages.
131    pub pending_queue: PendingQueueConfig,
132
133    /// Whether to negotiate and emit the compact MLS envelope for encrypted
134    /// messages (base64 of the binary `EncryptedMessage` form instead of JSON
135    /// with an integer-array ciphertext — roughly 2.7x smaller).
136    ///
137    /// Defaults to `true`. It only takes effect toward recipients that
138    /// advertise support in their key package (`env_versions`), so a mixed
139    /// fleet automatically stays on the JSON envelope. Parsing of inbound
140    /// compact envelopes is always on, independent of this flag; this gates
141    /// only advertising and emitting. The end-to-end sibling of
142    /// [`TransportConfig::binary_wire_enabled`], with an independent kill
143    /// switch because the two degrade separately.
144    pub compact_envelope_enabled: bool,
145
146    /// Whether to negotiate and seal the rich `__RICH_V1__` payload for
147    /// encrypted messages: quoted-reply context, rich media metadata, and
148    /// forward attribution carried *inside* the MLS ciphertext instead of on
149    /// the relay-visible outer message.
150    ///
151    /// Defaults to `true`. It only takes effect toward recipients that
152    /// advertise support in their key package (`rich_versions`); toward
153    /// everyone else the rich extras are silently dropped — never sent
154    /// cleartext. Parsing of inbound sealed payloads is always on,
155    /// independent of this flag; this gates only advertising and sealing.
156    /// Independent kill switch from
157    /// [`EncryptionConfig::compact_envelope_enabled`] — the two degrade
158    /// separately.
159    pub rich_payload_enabled: bool,
160
161    /// Whether to recover an inbound encrypted message that failed to decrypt,
162    /// rather than silently dropping it while telling the sender it was
163    /// delivered.
164    ///
165    /// Defaults to `true`. When enabled, an inbound encrypted DM (or media
166    /// chunk) that fails to decrypt is **not delivery-ACKed** and its id is not
167    /// left dedup-marked, so the sender keeps retrying instead of marking it
168    /// delivered — and each resend of a DM is re-sealed against the peer's
169    /// current session, so a resend can actually land. A message that stays
170    /// undeliverable settles as an honest `MessageFailed` once the sender's
171    /// retry budget lapses. When disabled, every decrypt failure falls back to
172    /// the legacy drop-and-ACK behavior.
173    ///
174    /// **Only an epoch mismatch additionally triggers a re-key.** A decrypt
175    /// failure *specifically* because the two sides disagree on the MLS epoch
176    /// (e.g. after a fork) also starts a rate-limited `session_reset`
177    /// key-package exchange to rebuild the channel. Failures that are *not* an
178    /// epoch mismatch — an AEAD / authentication failure, a discarded past
179    /// ratchet generation, a malformed frame — withhold the ACK but never
180    /// re-key: widening the re-key trigger to cover them would turn every
181    /// malformed frame into a teardown, which is an unbounded churn vector.
182    /// That separation is why the two classifications stay distinct.
183    ///
184    /// One consequence worth planning for: `MessageDecryptionFailed` is
185    /// **advisory, not terminal**, and fires once per failed *attempt* rather
186    /// than once per message. Treat it as "this attempt did not decrypt", and
187    /// read `MessageFailed` (or `FileReceiveFailed` for media) as the terminal
188    /// signal.
189    ///
190    /// **The re-key trigger is unauthenticated, by construction — do not read
191    /// this switch as "only genuine peers can cause a re-key".** An MLS epoch is
192    /// checked during *framing* validation, which happens before any AEAD,
193    /// sender-data, or signature check, and a 1:1 session slot id is derivable
194    /// from two public user ids. A hand-built frame carrying a wrong epoch
195    /// therefore reaches the recoverable classification with its sender still
196    /// entirely unverified: any party who can inject a frame can drive a re-key,
197    /// with no key material, no captured ciphertext and no session
198    /// (`test_forged_frame_reaches_session_desync_without_any_key_material`
199    /// builds one from scratch). This is inherent to MLS framing, not a defect,
200    /// and cannot be authenticated away — the credential a sender check would
201    /// compare against only exists once decrypt *succeeds*.
202    ///
203    /// What makes that safe is that acting on the trigger is **harmless**, not
204    /// that it is trusted: the re-key is confined to the claimed sender's own
205    /// session slot (so one derivable id cannot be aimed at other peers), is
206    /// rate-limited to one per peer per 30 s, discards no queued message, and
207    /// emits a `SESSION_REKEY_TRIGGERED` security warning so a sustained rate —
208    /// injection rather than a real fork — is visible to the app. The residual
209    /// is bounded re-key churn on a single pair: delivery delayed, never lost.
210    /// `OfflineProtocol::schedule_session_rekey` carries the full analysis.
211    pub crypto_recovery_enabled: bool,
212}
213
214impl Default for EncryptionConfig {
215    fn default() -> Self {
216        Self {
217            enabled: true,
218            auto_key_exchange: true,
219            store_pending: true,
220            // Fail closed by default (SEC-M3): a stock-config node must never
221            // silently degrade to plaintext because MLS was left uninitialized.
222            require_encryption: true,
223            pending_queue: PendingQueueConfig::default(),
224            compact_envelope_enabled: true,
225            rich_payload_enabled: true,
226            crypto_recovery_enabled: true,
227        }
228    }
229}
230
231impl EncryptionConfig {
232    /// Creates a new encryption config with encryption disabled.
233    pub fn disabled() -> Self {
234        Self {
235            enabled: false,
236            auto_key_exchange: false,
237            store_pending: false,
238            require_encryption: false,
239            pending_queue: PendingQueueConfig::default(),
240            compact_envelope_enabled: false,
241            rich_payload_enabled: false,
242            crypto_recovery_enabled: false,
243        }
244    }
245}
246
247/// Security configuration for transport and control-message hardening.
248#[derive(Debug, Clone, Default, Serialize, Deserialize)]
249pub struct SecurityConfig {
250    /// When `true`, control messages with no transport-level peer identity
251    /// (`transport_peer_id` is `None`) are rejected. When `false` (default),
252    /// missing transport identity emits a `SecurityWarning` but allows the
253    /// message through (best-effort / fail-open).
254    ///
255    /// # Why the default (`false`) is not the weak setting it once was
256    ///
257    /// This flag used to carry real weight, because a forged `sender` was
258    /// otherwise cheap: identities were app-chosen strings and the only thing
259    /// standing behind one was a trust-on-first-use pin, so first contact with
260    /// any peer was open to a man-in-the-middle and transport identity was the
261    /// only independent corroboration available.
262    ///
263    /// Under self-certifying addressing it is not. Every security-gated control
264    /// frame must now carry an Ed25519 signature — unconditionally, no longer
265    /// gated on this flag — and the signing key must derive to the address in
266    /// `sender`. A forged `sender` therefore requires the private key behind
267    /// that address, on first contact as much as on the thousandth. Transport
268    /// identity adds no authenticity on top of that; it only cross-checks a
269    /// claim the signature already proved.
270    ///
271    /// The strict match on frames claiming direct origin (`hop_count == 0`) runs
272    /// regardless of this flag, as it always has, and is unaffected.
273    ///
274    /// # What enabling it still does, and what it costs
275    ///
276    /// Exactly one thing: control frames arriving with **no** transport identity
277    /// at all are rejected instead of falling through to the signature gate.
278    ///
279    /// That is a real cost with no matching benefit on most deployments. Nostr
280    /// frames carry no transport identity by construction — the Nostr pubkey is
281    /// an install signing key, not the protocol id, so attaching it would fail
282    /// the strict match (see `nostr_message_received_inner`) — and relay frames
283    /// that arrive without a named sender are in the same position. Turning this
284    /// on rejects **every** control message on those paths, including the
285    /// key-package exchange that cold contact depends on. Mesh-forwarded frames
286    /// re-created by intermediate nodes legitimately lack identity too.
287    ///
288    /// Set to `true` only on a deployment that runs neither Nostr nor
289    /// sender-less relay delivery, and wants frames without transport identity
290    /// refused as a matter of policy rather than of authentication.
291    pub require_transport_identity: bool,
292}
293
294/// Transport-specific configuration.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct TransportConfig {
297    /// Whether BLE transport is enabled.
298    pub ble_enabled: bool,
299
300    /// Whether Wi-Fi Direct transport is enabled.
301    pub wifi_direct_enabled: bool,
302
303    /// Whether Internet transport is enabled.
304    pub internet_enabled: bool,
305
306    /// Whether Reticulum mesh transport is enabled.
307    /// Defaults to `false` because Reticulum requires external infrastructure
308    /// (a running Reticulum daemon or RNode hardware).
309    pub reticulum_enabled: bool,
310
311    /// Whether Nostr relay transport is enabled.
312    /// Defaults to `false` because Nostr requires relay configuration
313    /// and a secp256k1 keypair.
314    pub nostr_enabled: bool,
315
316    /// Whether outgoing Nostr frames are sealed into NIP-59 gift wraps.
317    ///
318    /// Defaults to `true`. With it off, the transport publishes the legacy
319    /// kind-4 event whose content is the whole protocol envelope in cleartext —
320    /// both usernames, the app id, the metadata map, the content type and a
321    /// millisecond timestamp, readable by every relay, permanently. Turn it off
322    /// only to interoperate with a relay that rejects kind 1059 outright.
323    ///
324    /// Send-side only, and never negotiated: inbound gift wraps are always
325    /// unsealed and inbound legacy frames always parsed, so whatever this is
326    /// set to, anything a peer sends us stays readable.
327    ///
328    /// It does still affect *outbound* reachability, in the one direction the
329    /// receive path cannot cover. A peer on a build that predates sealing
330    /// subscribes with `kinds: [4]` only, so it is never handed our kind-1059
331    /// events and has no NIP-44 layer to open one with — leaving it on makes
332    /// such a peer unreachable over Nostr (visibly: no ACK returns, so the
333    /// send fails through the retry ladder rather than vanishing). That is the
334    /// second reason to turn this off, alongside a relay that rejects
335    /// kind 1059.
336    pub nostr_sealing_enabled: bool,
337
338    /// Whether this install publishes MLS key packages to the relays and
339    /// resolves peers' published packages.
340    ///
341    /// Defaults to `true`, and takes effect only when `nostr_enabled` is on —
342    /// which is itself off by default, so nothing is published unless an app
343    /// deliberately turns Nostr on.
344    ///
345    /// Buys **cold first contact**: a peer known only by username becomes
346    /// reachable over Nostr with no prior key-package exchange over some other
347    /// transport, which is otherwise impossible. It also bounds the sealing
348    /// downgrade that follows a peer-key eviction, since a forgotten key is
349    /// re-resolved on the next send instead of waiting for a fresh exchange.
350    ///
351    /// **What it costs is worth stating plainly.** Publication is the first
352    /// thing this SDK emits to a relay unprompted: a small set of addressable
353    /// records sits at this install's routing tag and is refreshed as slots are
354    /// consumed, whether or not any message is ever sent. Their *content* is
355    /// sealed — a scraper filtering on the kind alone reads nothing, which
356    /// matters because an MLS key package carries its owner's username in the
357    /// leaf credential and cannot be stripped of it — but the existence of a
358    /// record at a given tag, and the timing of its refreshes, are visible to
359    /// every relay published to. Turn this off to keep the transport silent
360    /// until it has traffic, at the price of cold contact.
361    pub nostr_cold_contact_enabled: bool,
362
363    /// Whether to negotiate and emit the compact binary wire codec.
364    ///
365    /// Defaults to `true`. It only takes effect between two peers that both
366    /// advertise support (via their signed key package), so a mixed fleet
367    /// automatically stays on JSON. Decoding of binary frames is always on,
368    /// independent of this flag; this gates only advertising and emitting.
369    pub binary_wire_enabled: bool,
370}
371
372impl Default for TransportConfig {
373    fn default() -> Self {
374        Self {
375            ble_enabled: true,
376            wifi_direct_enabled: true,
377            internet_enabled: true,
378            reticulum_enabled: false,
379            nostr_enabled: false,
380            nostr_sealing_enabled: true,
381            nostr_cold_contact_enabled: true,
382            binary_wire_enabled: true,
383        }
384    }
385}
386
387/// Reliability configuration combining ACK, retry, and deduplication settings.
388#[derive(Debug, Clone, Default)]
389pub struct ReliabilityConfig {
390    /// ACK manager configuration.
391    pub ack: AckConfig,
392
393    /// Retry queue configuration.
394    pub retry: RetryConfig,
395
396    /// Deduplicator configuration.
397    pub dedup: DeduplicatorConfig,
398}
399
400/// Configuration for group messaging.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct GroupConfig {
403    /// Maximum number of members allowed in a single group.
404    ///
405    /// Per-member fan-out is O(N) when using mesh transports.
406    /// This cap prevents unbounded groups from overwhelming the network.
407    pub max_group_members: usize,
408
409    /// Whether to attempt relay server registration for groups.
410    ///
411    /// When `true` (default), groups are registered with the relay server
412    /// for optimized fan-out when Internet transport is available.
413    /// When `false`, groups always use per-member fan-out regardless of
414    /// transport.
415    ///
416    /// This gates *registration* only — the relay group registry is what
417    /// invite links resolve against, so it stays on independently of
418    /// [`Self::relay_broadcast_enabled`].
419    pub relay_enabled: bool,
420
421    /// Whether a relay-synced group may send via a single O(1) relay
422    /// broadcast instead of per-member fan-out.
423    ///
424    /// **Default `true`, and additionally gated on the relay's advertised
425    /// capabilities.** The flag alone does not select the broadcast: the
426    /// path is taken only when the connected relay also advertised the
427    /// `group_delivery_v3` capability in its `Authenticated` answer (v3 =
428    /// the v2 settled-report contract plus an address-aware group path —
429    /// members named in the identifier space the roster was registered in,
430    /// so the report is comparable against the MLS roster). Against
431    /// such a relay the broadcast keeps a delivery contract — the relay
432    /// answers every broadcast with a *settled* per-recipient delivery
433    /// report (`GroupMessageSent`) naming who took the message over a
434    /// socket and who took a device push, and the SDK re-sends a per-member
435    /// copy through the ordinary outbox/ACK/park ladder to every MLS roster
436    /// member the report does not account for. A lost report re-sends the
437    /// broadcast under the same logical message id (receiver and push dedup
438    /// absorb the duplicate fan-out) a bounded number of times, then
439    /// downgrades the message to full per-member fan-out. Against an older
440    /// relay the capability gate fails and every send takes per-member
441    /// fan-out — the fire-and-forget broadcast of the v1 relay (no presence
442    /// check, no push fallback, no persistence, "sent" answered before
443    /// delivery was known) is never taken, regardless of this flag.
444    ///
445    /// The one gap on the broadcast path: the tracker awaiting the report
446    /// lives in memory only, so a process death inside the report window
447    /// (up to `RELAY_BROADCAST_REPORT_TIMEOUT_SECS` per attempt) loses the
448    /// backstop with it — members the relay could not reach get no
449    /// per-member re-send, and nothing retries on restart. It needs both a
450    /// missed member and a kill in that window, and is strictly smaller than
451    /// the pre-report broadcast's exposure, but it is a real difference from
452    /// per-member fan-out, whose outbox entries persist for 7 days.
453    ///
454    /// Set `false` to force per-member fan-out even against a v2 relay —
455    /// the right choice for an app that must not lose a group message to a
456    /// mid-flight process kill.
457    /// Per-member fan-out (`__GRP_MLS_MSG__` as ordinary `SendMessage`
458    /// frames) inherits the full DM ladder end to end — outbox + ACK +
459    /// retry, relay write-ack + successor retry + offline push carrying the
460    /// ciphertext, park-on-unreachable with presence-driven flush, and
461    /// receiver-side deferred-ACK-after-decrypt — at the cost of O(N)
462    /// uplink frames per send.
463    ///
464    /// O(N) fan-out does **not** risk tripping the relay's rate limiter at
465    /// any group size. The platform bridge meters every relay-bound frame
466    /// through a client-side token bucket (`RelayRateLimiter`: 28 capacity,
467    /// 9/s refill) deliberately tighter than the relay's (30 burst, 10/s),
468    /// and a frame that cannot take a token is *deferred to a later poll
469    /// tick*, never dropped — so client spend stays at `28 + 9t` against a
470    /// server budget of `30 + 10t` and the fan-out self-paces below it
471    /// regardless of member count. Core enqueues all N frames at once; the
472    /// bridge drains that queue.
473    ///
474    /// What large groups actually cost on the per-member path is drain
475    /// latency. Frame N reaches the wire at roughly `(N - 28) / 9` seconds.
476    /// Since the ACK timer starts at local *enqueue* (`handle_send_success`)
477    /// and not at wire-confirm — `on_transport_send_confirmed` advances only
478    /// the welcome lifecycle — past roughly 118 members the tail of a single
479    /// fan-out exceeds the 10s ACK timeout and is retransmitted by the
480    /// ladder before it was ever written. That is wasted duplicate frames,
481    /// absorbed by receiver and push dedup via the stable outbox id, not
482    /// lost messages. Presence checks, typing, and receipts draw on the same
483    /// bucket and lower the threshold. This is the strongest reason to leave
484    /// the broadcast enabled for large groups.
485    pub relay_broadcast_enabled: bool,
486
487    /// Whether an incoming MLS membership commit is refused when the local
488    /// admin overlay does not authorize its committer.
489    ///
490    /// **Off by default, and turning it on is a decision about partition
491    /// risk, not a hardening toggle.** Refusing a commit means declining the
492    /// MLS merge: our epoch stays behind every member who did merge, and MLS
493    /// has no way back — the application has to re-invite us. Enforcement is
494    /// therefore fork-free only if every member reaches the *same* verdict,
495    /// and the admin overlay is replicated best-effort (role changes ride
496    /// unreconciled mesh notifications; a joiner gets a point-in-time
497    /// snapshot). Two members whose role maps merely disagree will partition
498    /// each other with no attacker involved.
499    ///
500    /// The check fails open on every *absent* input — no metadata, no admin
501    /// role stored, an unreadable roster — so the common lag case stays
502    /// harmless. It cannot detect *divergent* views, which is the residual
503    /// risk that keeps this opt-in. Enable it only for a closed deployment
504    /// that controls role distribution.
505    ///
506    /// With it off (the default), unauthorized membership changes are still
507    /// applied and reported: `group_unauthorized_membership_change` fires and
508    /// the roster events carry `authorized: false`. That reporting is
509    /// unaffected by this flag; the flag only decides whether the commit is
510    /// *also* refused.
511    ///
512    /// Rejection is receiver-local. The sender's message is still ACKed, so
513    /// a committer gets no signal that anyone refused, and members with the
514    /// flag off will apply a commit that members with it on rejected — do not
515    /// enable it on part of a fleet.
516    #[serde(default)]
517    pub enforce_admin_commits: bool,
518}
519
520impl Default for GroupConfig {
521    fn default() -> Self {
522        Self {
523            max_group_members: 256,
524            relay_enabled: true,
525            relay_broadcast_enabled: true,
526            enforce_admin_commits: false,
527        }
528    }
529}
530
531/// Main configuration for the Offline Protocol.
532#[derive(Debug, Clone)]
533pub struct ProtocolConfig {
534    /// Application identifier (required).
535    pub app_id: String,
536
537    /// Local profile selector (required).
538    ///
539    /// Selects which stored identity this instance runs as — one namespace per
540    /// `(app_id, profile)` pair — so an app hosting several accounts gives each
541    /// its own value, and an app hosting one can pass a constant like
542    /// `"default"`.
543    ///
544    /// It is **not** the device's identity. The wire identity is the
545    /// self-certifying address derived from the identity key held in this
546    /// profile's storage: it is minted on first start, reported by
547    /// [`crate::OfflineProtocol::local_address`], and it is what gets stamped
548    /// as `Message.sender`, what peers see as `NeighborDiscovered.peer_id`,
549    /// and what others use as `recipient`. An app cannot choose it, because
550    /// choosing it would mean claiming a key one does not hold.
551    ///
552    /// Under any configuration that reaches the wire the profile stays local.
553    /// The one exception is the explicit
554    /// [`EncryptionConfig::require_encryption`]` = false` opt-out, which lets a
555    /// plaintext send go out with MLS never initialized — no address has been
556    /// derived at that point, so the frame carries the profile as its sender
557    /// and the send emits
558    /// [`crate::events::SecurityWarningCode::PlaintextSend`]. The default
559    /// (`require_encryption = true`) fails such a send closed instead. Treat
560    /// the profile as private if that opt-out is in use.
561    pub profile: String,
562
563    /// Transport configuration.
564    pub transport: TransportConfig,
565
566    /// DORS (Dynamic Offline Relay Switch) configuration.
567    pub dors: DorsConfig,
568
569    /// Relay management configuration: whether this device carries traffic
570    /// for other people at all, and the battery floors under which it stops.
571    pub relay: RelayConfig,
572
573    /// How this device carries traffic for other people: how far frames
574    /// travel, how many neighbors each is handed to, and the ceilings that
575    /// keep forwarding from crowding the radio out.
576    ///
577    /// Whether to forward is [`Self::relay`]'s decision; this is the shape of
578    /// it once the answer is yes.
579    pub mesh_relay: MeshRelayConfig,
580
581    /// Path selection configuration.
582    pub path: PathConfig,
583
584    /// Reliability layer configuration.
585    pub reliability: ReliabilityConfig,
586
587    /// Encryption configuration for automatic MLS handling.
588    pub encryption: EncryptionConfig,
589
590    /// Initial TTL (Time-To-Live) for messages.
591    pub initial_ttl: u8,
592
593    /// Mesh group messaging configuration.
594    pub group: GroupConfig,
595
596    /// Security configuration for transport and control-message hardening.
597    pub security: SecurityConfig,
598}
599
600impl ProtocolConfig {
601    /// Creates a new protocol configuration.
602    ///
603    /// # Arguments
604    ///
605    /// * `app_id` - Application identifier
606    /// * `profile` - Local profile selector (storage namespace, never on the wire)
607    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
608        Self {
609            app_id: app_id.into(),
610            profile: profile.into(),
611            transport: TransportConfig::default(),
612            dors: DorsConfig::default(),
613            relay: RelayConfig::default(),
614            mesh_relay: MeshRelayConfig::default(),
615            path: PathConfig::default(),
616            reliability: ReliabilityConfig::default(),
617            encryption: EncryptionConfig::default(),
618            initial_ttl: DEFAULT_INITIAL_TTL,
619            group: GroupConfig::default(),
620            security: SecurityConfig::default(),
621        }
622    }
623
624    /// Creates a builder for more granular configuration.
625    pub fn builder(app_id: impl Into<String>, profile: impl Into<String>) -> ProtocolConfigBuilder {
626        ProtocolConfigBuilder::new(app_id, profile)
627    }
628
629    /// Validates the configuration.
630    ///
631    /// # Returns
632    ///
633    /// Returns `Ok(())` if valid, `Err` with a description of the problem if invalid.
634    pub fn validate(&self) -> crate::Result<()> {
635        // Both become storage keys — `app_id` and `profile` are the two inputs
636        // to the account namespace — so hold them to the same length cap and
637        // charset ban as every other identifier rather than only rejecting the
638        // empty string. Going through the core constructors keeps one policy
639        // instead of a second copy that can drift. Catching this here turns a
640        // later failure deep inside a storage backend into one clear
641        // configuration error.
642        offline_protocol_core::AppId::new(self.app_id.as_str())
643            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
644
645        offline_protocol_core::UserId::new(self.profile.as_str())
646            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
647
648        if self.initial_ttl == 0 {
649            return Err(crate::Error::InvalidConfiguration(
650                "initial_ttl must be greater than 0".to_string(),
651            ));
652        }
653
654        if !self.transport.ble_enabled
655            && !self.transport.wifi_direct_enabled
656            && !self.transport.internet_enabled
657            && !self.transport.reticulum_enabled
658            && !self.transport.nostr_enabled
659        {
660            return Err(crate::Error::InvalidConfiguration(
661                "At least one transport must be enabled".to_string(),
662            ));
663        }
664
665        if self.reliability.ack.default_timeout_ms == 0 {
666            return Err(crate::Error::InvalidConfiguration(
667                "ack.default_timeout_ms must be greater than 0".to_string(),
668            ));
669        }
670
671        if self.reliability.ack.max_pending_acks == 0 {
672            return Err(crate::Error::InvalidConfiguration(
673                "ack.max_pending_acks must be greater than 0".to_string(),
674            ));
675        }
676
677        if self.reliability.retry.initial_delay_ms == 0 {
678            return Err(crate::Error::InvalidConfiguration(
679                "retry.initial_delay_ms must be greater than 0".to_string(),
680            ));
681        }
682
683        if self.reliability.retry.max_delay_ms == 0 {
684            return Err(crate::Error::InvalidConfiguration(
685                "retry.max_delay_ms must be greater than 0".to_string(),
686            ));
687        }
688
689        if self.reliability.retry.initial_delay_ms > self.reliability.retry.max_delay_ms {
690            return Err(crate::Error::InvalidConfiguration(
691                "retry.initial_delay_ms must be <= retry.max_delay_ms".to_string(),
692            ));
693        }
694
695        if self.reliability.retry.outbox_max_lifetime_ms == 0
696            || self.reliability.retry.outbox_max_lifetime_ms > i64::MAX as u64
697        {
698            return Err(crate::Error::InvalidConfiguration(
699                "retry.outbox_max_lifetime_ms must be in 1..=i64::MAX".to_string(),
700            ));
701        }
702
703        if self.reliability.retry.pending_message_max_lifetime_ms == 0 {
704            return Err(crate::Error::InvalidConfiguration(
705                "retry.pending_message_max_lifetime_ms must be greater than 0".to_string(),
706            ));
707        }
708
709        if self.reliability.retry.pending_message_max_lifetime_ms > i64::MAX as u64 {
710            return Err(crate::Error::InvalidConfiguration(
711                "retry.pending_message_max_lifetime_ms must be <= i64::MAX".to_string(),
712            ));
713        }
714
715        if !self.reliability.retry.backoff_multiplier.is_finite()
716            || self.reliability.retry.backoff_multiplier < 1.0
717        {
718            return Err(crate::Error::InvalidConfiguration(
719                "retry.backoff_multiplier must be finite and >= 1.0".to_string(),
720            ));
721        }
722
723        // Checked outside the Bloom guard below, and that placement is the
724        // whole point: these two are the *only* dedup fields the UniFFI
725        // `DedupConfig` carries, so every constraint hidden behind
726        // `use_bloom_filter` is unreachable from a binding caller. A zero here
727        // is not a cosmetic problem — `Deduplicator` fails safe on the Bloom
728        // parameters, but nothing fails safe on these. At
729        // `max_tracked_messages == 0` the HashMap branch of `mark_seen` evicts
730        // on every insert, so the map holds one id and duplicate suppression is
731        // effectively off; `retention_time_secs == 0` expires every entry
732        // immediately for the same net result.
733        //
734        // What this rejects is the *degenerate* value, and that is the whole of
735        // the claim: a floor of 1 is not a floor on how well duplicates are
736        // suppressed. `max_tracked_messages == 1` behaves indistinguishably
737        // from `0`, and a two-second retention on a link whose retry backoff is
738        // longer than that suppresses nothing either. Choosing a window wide
739        // enough for the deployment is the application's call — the SDK refuses
740        // only the values that cannot be a choice, because nothing downstream
741        // spells them as a configuration error and the failure they produce
742        // (silently re-delivered messages) does not look like a config bug.
743        // Raising either floor to a policy minimum is a wider behavioural break
744        // and would need its own decision.
745        if self.reliability.dedup.max_tracked_messages == 0 {
746            return Err(crate::Error::InvalidConfiguration(
747                "reliability.dedup.max_tracked_messages must be greater than 0".to_string(),
748            ));
749        }
750
751        if self.reliability.dedup.retention_time_secs == 0 {
752            return Err(crate::Error::InvalidConfiguration(
753                "reliability.dedup.retention_time_secs must be greater than 0".to_string(),
754            ));
755        }
756
757        if self.reliability.dedup.use_bloom_filter {
758            if self.reliability.dedup.bloom_filter_bits == 0 {
759                return Err(crate::Error::InvalidConfiguration(
760                    "reliability.dedup.bloom_filter_bits must be greater than 0".to_string(),
761                ));
762            }
763
764            if self.reliability.dedup.bloom_hash_count == 0 {
765                return Err(crate::Error::InvalidConfiguration(
766                    "reliability.dedup.bloom_hash_count must be greater than 0".to_string(),
767                ));
768            }
769
770            if self.reliability.dedup.bloom_filter_count == 0 {
771                return Err(crate::Error::InvalidConfiguration(
772                    "reliability.dedup.bloom_filter_count must be greater than 0".to_string(),
773                ));
774            }
775
776            if self.reliability.dedup.bloom_rotation_secs == 0 {
777                return Err(crate::Error::InvalidConfiguration(
778                    "reliability.dedup.bloom_rotation_secs must be greater than 0".to_string(),
779                ));
780            }
781        }
782
783        if self.encryption.require_encryption && !self.encryption.enabled {
784            return Err(crate::Error::InvalidConfiguration(
785                "encryption.require_encryption requires encryption.enabled=true".to_string(),
786            ));
787        }
788
789        if self.encryption.pending_queue.max_pending_per_peer == 0 {
790            return Err(crate::Error::InvalidConfiguration(
791                "encryption.pending_queue.max_pending_per_peer must be greater than 0".to_string(),
792            ));
793        }
794
795        if self.encryption.pending_queue.max_pending_global == 0 {
796            return Err(crate::Error::InvalidConfiguration(
797                "encryption.pending_queue.max_pending_global must be greater than 0".to_string(),
798            ));
799        }
800
801        if self.encryption.pending_queue.max_pending_global
802            < self.encryption.pending_queue.max_pending_per_peer
803        {
804            return Err(crate::Error::InvalidConfiguration(
805                "encryption.pending_queue.max_pending_global must be >= max_pending_per_peer"
806                    .to_string(),
807            ));
808        }
809
810        if self.encryption.pending_queue.pending_ttl_ms == 0 {
811            return Err(crate::Error::InvalidConfiguration(
812                "encryption.pending_queue.pending_ttl_ms must be greater than 0".to_string(),
813            ));
814        }
815
816        if self.group.max_group_members == 0 {
817            return Err(crate::Error::InvalidConfiguration(
818                "group.max_group_members must be greater than 0".to_string(),
819            ));
820        }
821
822        Ok(())
823    }
824}
825
826/// Builder for ProtocolConfig with a fluent API.
827pub struct ProtocolConfigBuilder {
828    config: ProtocolConfig,
829}
830
831impl ProtocolConfigBuilder {
832    /// Creates a new builder.
833    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
834        Self {
835            config: ProtocolConfig::new(app_id, profile),
836        }
837    }
838
839    /// Configures transports.
840    pub fn transport(mut self, config: TransportConfig) -> Self {
841        self.config.transport = config;
842        self
843    }
844
845    /// Enables or disables BLE transport.
846    pub fn ble_enabled(mut self, enabled: bool) -> Self {
847        self.config.transport.ble_enabled = enabled;
848        self
849    }
850
851    /// Enables or disables Wi-Fi Direct transport.
852    pub fn wifi_direct_enabled(mut self, enabled: bool) -> Self {
853        self.config.transport.wifi_direct_enabled = enabled;
854        self
855    }
856
857    /// Enables or disables Internet transport.
858    pub fn internet_enabled(mut self, enabled: bool) -> Self {
859        self.config.transport.internet_enabled = enabled;
860        self
861    }
862
863    /// Enables or disables Nostr relay transport.
864    pub fn nostr_enabled(mut self, enabled: bool) -> Self {
865        self.config.transport.nostr_enabled = enabled;
866        self
867    }
868
869    /// Enables or disables sealing of outgoing Nostr frames (default on).
870    pub fn nostr_sealing_enabled(mut self, enabled: bool) -> Self {
871        self.config.transport.nostr_sealing_enabled = enabled;
872        self
873    }
874
875    /// Enables or disables Nostr key-package publication and peer resolution
876    /// (default on; see [`TransportConfig::nostr_cold_contact_enabled`]).
877    pub fn nostr_cold_contact_enabled(mut self, enabled: bool) -> Self {
878        self.config.transport.nostr_cold_contact_enabled = enabled;
879        self
880    }
881
882    /// Configures DORS (Dynamic Offline Relay Switch).
883    pub fn dors(mut self, config: DorsConfig) -> Self {
884        self.config.dors = config;
885        self
886    }
887
888    /// Enables online-first mode (prefer Internet when available).
889    pub fn online_first(mut self, enabled: bool) -> Self {
890        self.config.dors.prefer_online = enabled;
891        self
892    }
893
894    /// Configures relay management.
895    pub fn relay(mut self, config: RelayConfig) -> Self {
896        self.config.relay = config;
897        self
898    }
899
900    /// Configures how traffic is carried for other people.
901    pub fn mesh_relay(mut self, config: MeshRelayConfig) -> Self {
902        self.config.mesh_relay = config;
903        self
904    }
905
906    /// Configures path selection.
907    pub fn path(mut self, config: PathConfig) -> Self {
908        self.config.path = config;
909        self
910    }
911
912    /// Configures reliability layer.
913    pub fn reliability(mut self, config: ReliabilityConfig) -> Self {
914        self.config.reliability = config;
915        self
916    }
917
918    /// Sets the initial TTL for messages.
919    pub fn initial_ttl(mut self, ttl: u8) -> Self {
920        self.config.initial_ttl = ttl;
921        self
922    }
923
924    /// Configures encryption settings.
925    pub fn encryption(mut self, config: EncryptionConfig) -> Self {
926        self.config.encryption = config;
927        self
928    }
929
930    /// Enables or disables automatic encryption.
931    pub fn encryption_enabled(mut self, enabled: bool) -> Self {
932        self.config.encryption.enabled = enabled;
933        self
934    }
935
936    /// Enables or disables automatic key exchange on peer discovery.
937    pub fn auto_key_exchange(mut self, enabled: bool) -> Self {
938        self.config.encryption.auto_key_exchange = enabled;
939        self
940    }
941
942    /// Enables or disables storing pending messages for later encryption.
943    pub fn store_pending_messages(mut self, enabled: bool) -> Self {
944        self.config.encryption.store_pending = enabled;
945        self
946    }
947
948    /// Enables or disables strict encryption requirement.
949    pub fn require_encryption(mut self, required: bool) -> Self {
950        self.config.encryption.require_encryption = required;
951        self
952    }
953
954    /// Configures pending encrypted message queue bounds and overflow behavior.
955    pub fn pending_queue(mut self, config: PendingQueueConfig) -> Self {
956        self.config.encryption.pending_queue = config;
957        self
958    }
959
960    /// Configures group messaging settings.
961    pub fn group(mut self, config: GroupConfig) -> Self {
962        self.config.group = config;
963        self
964    }
965
966    /// Sets the maximum number of members allowed in a single group.
967    pub fn max_group_members(mut self, max: usize) -> Self {
968        self.config.group.max_group_members = max;
969        self
970    }
971
972    /// Sets whether groups should attempt relay server registration.
973    pub fn group_relay_enabled(mut self, enabled: bool) -> Self {
974        self.config.group.relay_enabled = enabled;
975        self
976    }
977
978    /// Sets whether relay-synced groups may send via a single relay broadcast
979    /// instead of per-member fan-out. See
980    /// [`GroupConfig::relay_broadcast_enabled`] — on by default, but only
981    /// ever taken against a relay that advertised the `group_delivery_v3`
982    /// capability, whose settled, address-space delivery report gives the
983    /// broadcast a per-recipient delivery contract.
984    pub fn group_relay_broadcast_enabled(mut self, enabled: bool) -> Self {
985        self.config.group.relay_broadcast_enabled = enabled;
986        self
987    }
988
989    /// Sets whether unauthorized MLS membership commits are refused rather
990    /// than applied-and-reported. See
991    /// [`GroupConfig::enforce_admin_commits`] — off by default, and enabling
992    /// it risks permanently forking members whose admin overlay disagrees.
993    pub fn group_enforce_admin_commits(mut self, enabled: bool) -> Self {
994        self.config.group.enforce_admin_commits = enabled;
995        self
996    }
997
998    /// Configures security settings.
999    pub fn security(mut self, config: SecurityConfig) -> Self {
1000        self.config.security = config;
1001        self
1002    }
1003
1004    /// Sets whether transport identity is required for control messages.
1005    pub fn require_transport_identity(mut self, required: bool) -> Self {
1006        self.config.security.require_transport_identity = required;
1007        self
1008    }
1009
1010    /// Builds and validates the configuration.
1011    ///
1012    /// # Returns
1013    ///
1014    /// Returns `Ok(ProtocolConfig)` if valid, `Err` otherwise.
1015    pub fn build(self) -> crate::Result<ProtocolConfig> {
1016        self.config.validate()?;
1017        Ok(self.config)
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    #[test]
1026    fn test_config_creation() {
1027        let config = ProtocolConfig::new("test-app", "user123");
1028        assert_eq!(config.app_id, "test-app");
1029        assert_eq!(config.profile, "user123");
1030        assert_eq!(config.initial_ttl, 8);
1031    }
1032
1033    #[test]
1034    fn test_config_validation_success() {
1035        let config = ProtocolConfig::new("test-app", "user123");
1036        assert!(config.validate().is_ok());
1037    }
1038
1039    #[test]
1040    fn test_config_validation_empty_app_id() {
1041        let config = ProtocolConfig::new("", "user123");
1042        assert!(config.validate().is_err());
1043    }
1044
1045    #[test]
1046    fn test_config_validation_empty_user_id() {
1047        let config = ProtocolConfig::new("test-app", "");
1048        assert!(config.validate().is_err());
1049    }
1050
1051    #[test]
1052    fn config_validation_enforces_the_id_length_cap() {
1053        // An unbounded id becomes an unbounded storage key and an unbounded
1054        // wire field; the cap is the same one the core newtypes apply.
1055        let at_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN);
1056        let over_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN + 1);
1057
1058        assert!(ProtocolConfig::new("test-app", &at_cap).validate().is_ok());
1059        assert!(ProtocolConfig::new(&at_cap, "user123").validate().is_ok());
1060        assert!(ProtocolConfig::new("test-app", &over_cap)
1061            .validate()
1062            .is_err());
1063        assert!(ProtocolConfig::new(&over_cap, "user123")
1064            .validate()
1065            .is_err());
1066    }
1067
1068    #[test]
1069    fn config_validation_enforces_the_id_charset() {
1070        // Path separators and the KV namespace separator would let an id
1071        // escape or collide with another account's storage keys.
1072        for hostile in ["a/b", "a\\b", "a:b", ".", "..", "a\u{0}b", "a\nb"] {
1073            assert!(
1074                ProtocolConfig::new("test-app", hostile).validate().is_err(),
1075                "user_id {hostile:?} must be rejected"
1076            );
1077            assert!(
1078                ProtocolConfig::new(hostile, "user123").validate().is_err(),
1079                "app_id {hostile:?} must be rejected"
1080            );
1081        }
1082    }
1083
1084    #[test]
1085    fn config_validation_accepts_an_address_shaped_id() {
1086        // The self-certifying address format must pass the id policy: the
1087        // bech32m charset is plain lowercase alphanumerics.
1088        let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
1089        assert!(ProtocolConfig::new("test-app", address).validate().is_ok());
1090    }
1091
1092    #[test]
1093    fn test_config_validation_zero_ttl() {
1094        let mut config = ProtocolConfig::new("test-app", "user123");
1095        config.initial_ttl = 0;
1096        assert!(config.validate().is_err());
1097    }
1098
1099    #[test]
1100    fn test_config_validation_no_transports() {
1101        let mut config = ProtocolConfig::new("test-app", "user123");
1102        config.transport.ble_enabled = false;
1103        config.transport.wifi_direct_enabled = false;
1104        config.transport.internet_enabled = false;
1105        assert!(config.validate().is_err());
1106    }
1107
1108    #[test]
1109    fn test_config_validation_retry_initial_delay_must_be_positive() {
1110        let mut config = ProtocolConfig::new("test-app", "user123");
1111        config.reliability.retry.initial_delay_ms = 0;
1112        assert!(config.validate().is_err());
1113    }
1114
1115    #[test]
1116    fn test_config_validation_retry_delay_bounds() {
1117        let mut config = ProtocolConfig::new("test-app", "user123");
1118        config.reliability.retry.initial_delay_ms = 2000;
1119        config.reliability.retry.max_delay_ms = 1000;
1120        assert!(config.validate().is_err());
1121    }
1122
1123    #[test]
1124    fn test_config_validation_retry_backoff_multiplier_bounds() {
1125        let mut config = ProtocolConfig::new("test-app", "user123");
1126        config.reliability.retry.backoff_multiplier = 0.5;
1127        assert!(config.validate().is_err());
1128    }
1129
1130    #[test]
1131    fn test_config_validation_rejects_zero_bloom_parameters() {
1132        let mutations: [fn(&mut DeduplicatorConfig); 4] = [
1133            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_bits = 0,
1134            |dedup: &mut DeduplicatorConfig| dedup.bloom_hash_count = 0,
1135            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_count = 0,
1136            |dedup: &mut DeduplicatorConfig| dedup.bloom_rotation_secs = 0,
1137        ];
1138
1139        for mutate in mutations {
1140            let mut config = ProtocolConfig::new("test-app", "user123");
1141            config.reliability.dedup.use_bloom_filter = true;
1142            mutate(&mut config.reliability.dedup);
1143            assert!(config.validate().is_err());
1144        }
1145    }
1146
1147    #[test]
1148    fn test_config_builder() {
1149        let config = ProtocolConfig::builder("test-app", "user123")
1150            .ble_enabled(true)
1151            .wifi_direct_enabled(false)
1152            .online_first(true)
1153            .initial_ttl(10)
1154            .build()
1155            .unwrap();
1156
1157        assert_eq!(config.app_id, "test-app");
1158        assert!(config.transport.ble_enabled);
1159        assert!(!config.transport.wifi_direct_enabled);
1160        assert!(config.dors.prefer_online);
1161        assert_eq!(config.initial_ttl, 10);
1162    }
1163
1164    #[test]
1165    fn test_transport_config_default() {
1166        let transport = TransportConfig::default();
1167        assert!(transport.ble_enabled);
1168        assert!(transport.wifi_direct_enabled);
1169        assert!(transport.internet_enabled);
1170    }
1171
1172    #[test]
1173    fn test_reliability_config_default() {
1174        let reliability = ReliabilityConfig::default();
1175        assert_eq!(reliability.ack.default_timeout_ms, 10000);
1176        assert_eq!(reliability.retry.max_retries, 10);
1177        // 7 days — must match the app-layer presence-flush window and the
1178        // fallback values hardcoded in the RN native bridges
1179        // (OfflineProtocolModule.kt / OfflineProtocolModule.swift).
1180        assert_eq!(reliability.retry.outbox_max_lifetime_ms, 604_800_000);
1181        // 5 min ceiling — also mirrored by the RN bridge fallbacks.
1182        assert_eq!(reliability.retry.max_delay_ms, 300_000);
1183        assert_eq!(reliability.dedup.max_tracked_messages, 1000);
1184    }
1185
1186    #[test]
1187    fn test_encryption_config_default() {
1188        let encryption = EncryptionConfig::default();
1189        assert!(encryption.enabled);
1190        assert!(encryption.auto_key_exchange);
1191        assert!(encryption.store_pending);
1192        // SEC-M3: encryption is required by default; plaintext is an
1193        // explicit opt-out, never a silent fallback.
1194        assert!(encryption.require_encryption);
1195        assert_eq!(encryption.pending_queue.max_pending_per_peer, 64);
1196        assert_eq!(encryption.pending_queue.max_pending_global, 4096);
1197        assert_eq!(
1198            encryption.pending_queue.pending_ttl_ms,
1199            DEFAULT_PENDING_TTL_MS
1200        );
1201        assert_eq!(
1202            encryption.pending_queue.overflow_policy,
1203            OverflowPolicy::DropOldest
1204        );
1205    }
1206
1207    #[test]
1208    fn test_encryption_config_disabled() {
1209        let encryption = EncryptionConfig::disabled();
1210        assert!(!encryption.enabled);
1211        assert!(!encryption.auto_key_exchange);
1212        assert!(!encryption.store_pending);
1213        assert!(!encryption.require_encryption);
1214    }
1215
1216    #[test]
1217    fn test_config_builder_with_encryption() {
1218        let config = ProtocolConfig::builder("test-app", "user123")
1219            .encryption_enabled(true)
1220            .auto_key_exchange(true)
1221            .store_pending_messages(false)
1222            .require_encryption(true)
1223            .pending_queue(PendingQueueConfig {
1224                max_pending_per_peer: 32,
1225                max_pending_global: 512,
1226                pending_ttl_ms: 30_000,
1227                overflow_policy: OverflowPolicy::DropNewest,
1228                ..Default::default()
1229            })
1230            .build()
1231            .unwrap();
1232
1233        assert!(config.encryption.enabled);
1234        assert!(config.encryption.auto_key_exchange);
1235        assert!(!config.encryption.store_pending);
1236        assert!(config.encryption.require_encryption);
1237        assert_eq!(config.encryption.pending_queue.max_pending_per_peer, 32);
1238        assert_eq!(config.encryption.pending_queue.max_pending_global, 512);
1239        assert_eq!(config.encryption.pending_queue.pending_ttl_ms, 30_000);
1240        assert_eq!(
1241            config.encryption.pending_queue.overflow_policy,
1242            OverflowPolicy::DropNewest
1243        );
1244    }
1245
1246    #[test]
1247    fn test_config_validation_require_encryption_requires_enabled() {
1248        let mut config = ProtocolConfig::new("test-app", "user123");
1249        config.encryption.enabled = false;
1250        config.encryption.require_encryption = true;
1251        assert!(config.validate().is_err());
1252    }
1253
1254    #[test]
1255    fn test_config_validation_pending_queue_requires_positive_limits() {
1256        let mut config = ProtocolConfig::new("test-app", "user123");
1257        config.encryption.pending_queue.max_pending_per_peer = 0;
1258        assert!(config.validate().is_err());
1259
1260        let mut config = ProtocolConfig::new("test-app", "user123");
1261        config.encryption.pending_queue.max_pending_global = 0;
1262        assert!(config.validate().is_err());
1263
1264        let mut config = ProtocolConfig::new("test-app", "user123");
1265        config.encryption.pending_queue.pending_ttl_ms = 0;
1266        assert!(config.validate().is_err());
1267    }
1268
1269    #[test]
1270    fn test_config_validation_pending_queue_global_must_cover_per_peer() {
1271        let mut config = ProtocolConfig::new("test-app", "user123");
1272        config.encryption.pending_queue.max_pending_per_peer = 100;
1273        config.encryption.pending_queue.max_pending_global = 99;
1274        assert!(config.validate().is_err());
1275    }
1276
1277    #[test]
1278    fn test_group_config_default() {
1279        let group = GroupConfig::default();
1280        assert_eq!(group.max_group_members, 256);
1281        // Registration and broadcast both default on. Registration must stay
1282        // on because invite links resolve against the relay group registry.
1283        // The broadcast default is safe because the flag alone never selects
1284        // the path: it is additionally gated on the relay advertising the
1285        // `group_delivery_v3` capability, whose settled, address-space
1286        // delivery report is what gives the broadcast a per-recipient
1287        // delivery contract.
1288        assert!(group.relay_enabled);
1289        assert!(group.relay_broadcast_enabled);
1290        // Commit enforcement must stay OFF by default. On, a member whose
1291        // admin overlay merely lagged would refuse a legitimate commit and
1292        // fork itself out of a healthy group with no attacker involved —
1293        // unrecoverable without an app-level re-invite. Flipping this default
1294        // is a fleet-wide partition decision, not a hardening tweak.
1295        assert!(!group.enforce_admin_commits);
1296    }
1297
1298    #[test]
1299    fn test_group_enforce_admin_commits_builder_opt_in() {
1300        let config = ProtocolConfig::builder("test-app", "user123")
1301            .group_enforce_admin_commits(true)
1302            .build()
1303            .unwrap();
1304        assert!(config.group.enforce_admin_commits);
1305        assert!(
1306            config.group.relay_enabled && config.group.relay_broadcast_enabled,
1307            "opting into enforcement must not disturb the relay flags"
1308        );
1309    }
1310
1311    #[test]
1312    fn test_group_relay_broadcast_builder_opt_out() {
1313        let config = ProtocolConfig::builder("test-app", "user123")
1314            .group_relay_broadcast_enabled(false)
1315            .build()
1316            .unwrap();
1317        assert!(!config.group.relay_broadcast_enabled);
1318        assert!(
1319            config.group.relay_enabled,
1320            "opting out of the broadcast must not disturb registration"
1321        );
1322    }
1323
1324    #[test]
1325    fn test_config_has_default_group_config() {
1326        let config = ProtocolConfig::new("test-app", "user123");
1327        assert_eq!(config.group.max_group_members, 256);
1328    }
1329
1330    #[test]
1331    fn test_config_validation_zero_max_group_members() {
1332        let mut config = ProtocolConfig::new("test-app", "user123");
1333        config.group.max_group_members = 0;
1334        assert!(config.validate().is_err());
1335    }
1336
1337    #[test]
1338    fn test_config_validation_positive_max_group_members() {
1339        let mut config = ProtocolConfig::new("test-app", "user123");
1340        config.group.max_group_members = 1;
1341        assert!(config.validate().is_ok());
1342    }
1343
1344    #[test]
1345    fn test_config_builder_max_group_members() {
1346        let config = ProtocolConfig::builder("test-app", "user123")
1347            .max_group_members(50)
1348            .build()
1349            .unwrap();
1350        assert_eq!(config.group.max_group_members, 50);
1351    }
1352
1353    #[test]
1354    fn test_config_builder_group_config() {
1355        let config = ProtocolConfig::builder("test-app", "user123")
1356            .group(GroupConfig {
1357                max_group_members: 128,
1358                ..Default::default()
1359            })
1360            .build()
1361            .unwrap();
1362        assert_eq!(config.group.max_group_members, 128);
1363    }
1364
1365    #[test]
1366    fn test_config_builder_zero_max_group_members_rejected() {
1367        let result = ProtocolConfig::builder("test-app", "user123")
1368            .max_group_members(0)
1369            .build();
1370        assert!(result.is_err());
1371    }
1372
1373    /// The RN bridges never reach [`DEFAULT_PENDING_TTL_MS`]: each layer
1374    /// substitutes its own fallback when the app omits `pendingTtlMs`, and the
1375    /// JS layer always materializes the field before it crosses the bridge. So
1376    /// the Rust default is only the RN default for as long as these three
1377    /// literals agree with it — they silently drifted to the pre-deferred-ACK
1378    /// 2-minute window once already, which is the window this constant was
1379    /// raised to replace.
1380    ///
1381    /// The sibling guard for the ACK/retry fallbacks lives in
1382    /// `offline-protocol-reliability`; this one has to live here because that
1383    /// crate cannot depend on this one.
1384    #[test]
1385    fn rn_bridge_pending_ttl_fallbacks_match_rust_default() {
1386        let rn_root =
1387            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native");
1388
1389        // Each entry is (path, the fallback that file must contain). The
1390        // pattern names the field it guards, not just the literal, so deleting
1391        // the fallback cannot be masked by the same number appearing on some
1392        // other line. Matched against the source with whitespace squeezed, so a
1393        // line wrap between the field and its `?:`/`??` doesn't hide it.
1394        let expected = [
1395            (
1396                rn_root.join("src/index.ts"),
1397                format!("pendingTtlMs ?? {DEFAULT_PENDING_TTL_MS}"),
1398            ),
1399            (
1400                rn_root.join("android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt"),
1401                format!(
1402                    "\"pending_ttl_ms\") ?: {}L",
1403                    format_underscored(DEFAULT_PENDING_TTL_MS)
1404                ),
1405            ),
1406            (
1407                rn_root.join("ios/EncryptionConfigReader.swift"),
1408                format!(
1409                    "\"pending_ttl_ms\") ?? {}",
1410                    format_underscored(DEFAULT_PENDING_TTL_MS)
1411                ),
1412            ),
1413        ];
1414
1415        // The guard only applies in the repo checkout; skip when the bindings
1416        // tree isn't present (e.g. a vendored crate). Read every file up front
1417        // so a moved or renamed bridge skips the whole guard rather than
1418        // silently dropping the checks that come after it.
1419        let Some(sources) = expected
1420            .iter()
1421            .map(|(path, _)| std::fs::read_to_string(path).ok())
1422            .collect::<Option<Vec<_>>>()
1423        else {
1424            eprintln!("bindings tree not present, skipping RN pending-TTL drift check");
1425            return;
1426        };
1427
1428        for ((path, fallback), source) in expected.iter().zip(&sources) {
1429            let squeezed = source.split_whitespace().collect::<Vec<_>>().join(" ");
1430            assert!(
1431                squeezed.contains(fallback.as_str()),
1432                "RN pending-queue TTL fallback drifted from DEFAULT_PENDING_TTL_MS: \
1433                 expected `{fallback}` in {}",
1434                path.display()
1435            );
1436        }
1437    }
1438
1439    /// Drift guard: every public surface that documents
1440    /// [`EncryptionConfig::crypto_recovery_enabled`] must state that the re-key
1441    /// trigger is unauthenticated, and must not carry the retired claim that it
1442    /// re-keys only on genuine peer traffic.
1443    ///
1444    /// This is guarded because it has already drifted once. The trigger was
1445    /// hardened and honestly documented in-code, but that pass corrected only
1446    /// `session.rs` and the internal architecture notes — six integrator-facing
1447    /// surfaces kept promising that an injected frame could never drive a
1448    /// re-key, and framed the residual as replay-only, while
1449    /// `test_forged_frame_reaches_session_desync_without_any_key_material` in
1450    /// `offline-protocol-mls` proved the opposite in the same workspace. Nothing
1451    /// else fails when these lag: the code stays correct and the docs quietly
1452    /// promise a guarantee it does not make, which is the worst shape for a
1453    /// security claim to fail in.
1454    #[test]
1455    fn crypto_recovery_docs_state_the_unauthenticated_trigger() {
1456        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1457        let surfaces = [
1458            "crates/offline-protocol/src/config.rs",
1459            "crates/offline-protocol-uniffi/src/offline_protocol.udl",
1460            "bindings/react-native/src/types.ts",
1461            "docs/mls-integration.md",
1462            "docs/configuration.md",
1463            "docs/api-reference.md",
1464        ];
1465
1466        // Retired claims. Each was false for the same reason: an MLS epoch is
1467        // read during framing validation, before the sender is authenticated.
1468        // Split across `concat!` so the banned phrases never appear verbatim in
1469        // this file — belt-and-braces on top of the `#[cfg(test)]` truncation
1470        // below, so the guard survives either defense being refactored away.
1471        let retired = [
1472            concat!("never re-keys on ", "injected garbage"),
1473            concat!("injected garbage ", "can't drive"),
1474            concat!("injecting ", "garbage"),
1475        ];
1476
1477        // The marker every surface must carry, concat-split for the inverse
1478        // reason: written verbatim, this needle alone would satisfy its own
1479        // check for config.rs no matter what the rustdoc says.
1480        let marker = concat!("re-key trigger is ", "unauthenticated");
1481
1482        for surface in surfaces {
1483            let path = root.join(surface);
1484            let source = std::fs::read_to_string(&path)
1485                .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
1486            // Scan only up to the first `#[cfg(test)]` token — for config.rs,
1487            // the non-test source. This test necessarily names the marker (doc
1488            // comment, panic message), so scanning its own module would satisfy
1489            // the marker check for the file it lives in even after the rustdoc
1490            // dropped it. The other surfaces don't contain the token and pass
1491            // through whole.
1492            let scanned = source.split("#[cfg(test)]").next().unwrap_or(&source);
1493            // Squeezed so a line wrap inside a phrase cannot hide it, and
1494            // lowercased so a sentence-initial capital cannot either. Standalone
1495            // comment/markup continuation tokens (`///`, `//`, `*`, `>`) are
1496            // dropped, because these six surfaces wrap prose in four different
1497            // comment syntaxes and a reflow would otherwise wedge one into the
1498            // middle of a phrase — a guard that fails on `cargo fmt` teaches
1499            // people to delete it.
1500            let squeezed = scanned
1501                .split_whitespace()
1502                .filter(|tok| !tok.chars().all(|c| matches!(c, '/' | '*' | '>')))
1503                .collect::<Vec<_>>()
1504                .join(" ")
1505                .to_lowercase();
1506
1507            for claim in retired {
1508                assert!(
1509                    !squeezed.contains(claim),
1510                    "{surface} carries the retired claim `{claim}`. The re-key \
1511                     trigger is reachable by anyone who can inject a frame — see \
1512                     test_forged_frame_reaches_session_desync_without_any_key_material"
1513                );
1514            }
1515
1516            assert!(
1517                squeezed.contains(marker),
1518                "{surface} documents crypto_recovery_enabled but no longer states \
1519                 that the re-key trigger is unauthenticated. Say so plainly, or an \
1520                 integrator will read a sustained SESSION_REKEY_TRIGGERED rate as a \
1521                 bug rather than as the injection signal it exists to surface."
1522            );
1523        }
1524    }
1525
1526    /// Renders `1800000` as `1_800_000`, the form Kotlin and Swift use.
1527    fn format_underscored(value: u64) -> String {
1528        let digits = value.to_string();
1529        let mut out = String::new();
1530        for (i, c) in digits.chars().enumerate() {
1531            if i > 0 && (digits.len() - i) % 3 == 0 {
1532                out.push('_');
1533            }
1534            out.push(c);
1535        }
1536        out
1537    }
1538}