Skip to main content

offline_protocol/
config.rs

1//! Protocol configuration.
2
3use crate::constants::DEFAULT_INITIAL_TTL;
4use crate::protocol::mesh_relay::{MeshRelayConfig, RELAY_QUEUE_MAX_OVERDUE};
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 this install publishes a username discovery record and can
364    /// resolve usernames to the devices claiming them.
365    ///
366    /// Defaults to **`false`**, unlike `nostr_cold_contact_enabled`, and gates
367    /// publication and resolution together. It also *requires* cold contact:
368    /// a discovery record points at an address whose key packages are what a
369    /// resolver fetches next, so with cold contact off the claim resolves and
370    /// then dead-ends one hop later. The two are hard-coupled in the transport
371    /// rather than merely documented.
372    ///
373    /// Buys back **reach by username**, which the addressing migration removed:
374    /// a stranger who knows only a name can find the addresses claiming it and
375    /// open a session. The name is published as the app's configured `profile`,
376    /// normalized to NFC and lowercase.
377    ///
378    /// **Default-off is the deliberate choice, and the reason is disclosure.**
379    /// Publishing binds a human-readable name to an address in a public place.
380    /// That is materially more than the key-package record's "an install with
381    /// this tag exists": the mapping *is* the payload. The record is sealed to
382    /// a key derived from the username, so a relay scraping by kind alone reads
383    /// nothing, but anyone who guesses the name can compute the tag, confirm a
384    /// claim exists, and read it. That is the same class of exposure a public
385    /// user directory has, and it is a decision an app should make explicitly.
386    ///
387    /// **What the directory is not: authoritative.** Anyone may publish any
388    /// claim at any name. Every record is a claim that *some key* asserts a
389    /// name, and a resolution returns the whole set of claimants with their
390    /// verification status. An app that silently picks the first result has
391    /// converted a non-authoritative directory into an authoritative-looking
392    /// one; the user must confirm out of band. See
393    /// `docs/spec/username-discovery.md`.
394    pub nostr_username_discovery_enabled: bool,
395
396    /// Whether to negotiate and emit the compact binary wire codec.
397    ///
398    /// Defaults to `true`. It only takes effect between two peers that both
399    /// advertise support (via their signed key package), so a mixed fleet
400    /// automatically stays on JSON. Decoding of binary frames is always on,
401    /// independent of this flag; this gates only advertising and emitting.
402    pub binary_wire_enabled: bool,
403}
404
405impl Default for TransportConfig {
406    fn default() -> Self {
407        Self {
408            ble_enabled: true,
409            wifi_direct_enabled: true,
410            internet_enabled: true,
411            reticulum_enabled: false,
412            nostr_enabled: false,
413            nostr_sealing_enabled: true,
414            nostr_cold_contact_enabled: true,
415            nostr_username_discovery_enabled: false,
416            binary_wire_enabled: true,
417        }
418    }
419}
420
421/// Reliability configuration combining ACK, retry, and deduplication settings.
422#[derive(Debug, Clone, Default)]
423pub struct ReliabilityConfig {
424    /// ACK manager configuration.
425    pub ack: AckConfig,
426
427    /// Retry queue configuration.
428    pub retry: RetryConfig,
429
430    /// Deduplicator configuration.
431    pub dedup: DeduplicatorConfig,
432}
433
434/// Configuration for group messaging.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct GroupConfig {
437    /// Maximum number of members allowed in a single group.
438    ///
439    /// Per-member fan-out is O(N) when using mesh transports.
440    /// This cap prevents unbounded groups from overwhelming the network.
441    pub max_group_members: usize,
442
443    /// Whether to attempt relay server registration for groups.
444    ///
445    /// When `true` (default), groups are registered with the relay server
446    /// for optimized fan-out when Internet transport is available.
447    /// When `false`, groups always use per-member fan-out regardless of
448    /// transport.
449    ///
450    /// This gates *registration* only — the relay group registry is what
451    /// invite links resolve against, so it stays on independently of
452    /// [`Self::relay_broadcast_enabled`].
453    pub relay_enabled: bool,
454
455    /// Whether a relay-synced group may send via a single O(1) relay
456    /// broadcast instead of per-member fan-out.
457    ///
458    /// **Default `true`, and additionally gated on the relay's advertised
459    /// capabilities.** The flag alone does not select the broadcast: the
460    /// path is taken only when the connected relay also advertised the
461    /// `group_delivery_v3` capability in its `Authenticated` answer (v3 =
462    /// the v2 settled-report contract plus an address-aware group path —
463    /// members named in the identifier space the roster was registered in,
464    /// so the report is comparable against the MLS roster). Against
465    /// such a relay the broadcast keeps a delivery contract — the relay
466    /// answers every broadcast with a *settled* per-recipient delivery
467    /// report (`GroupMessageSent`) naming who took the message over a
468    /// socket and who took a device push, and the SDK re-sends a per-member
469    /// copy through the ordinary outbox/ACK/park ladder to every MLS roster
470    /// member the report does not account for. A lost report re-sends the
471    /// broadcast under the same logical message id (receiver and push dedup
472    /// absorb the duplicate fan-out) a bounded number of times, then
473    /// downgrades the message to full per-member fan-out. Against an older
474    /// relay the capability gate fails and every send takes per-member
475    /// fan-out — the fire-and-forget broadcast of the v1 relay (no presence
476    /// check, no push fallback, no persistence, "sent" answered before
477    /// delivery was known) is never taken, regardless of this flag.
478    ///
479    /// The one gap on the broadcast path: the tracker awaiting the report
480    /// lives in memory only, so a process death inside the report window
481    /// (up to `RELAY_BROADCAST_REPORT_TIMEOUT_SECS` per attempt) loses the
482    /// backstop with it — members the relay could not reach get no
483    /// per-member re-send, and nothing retries on restart. It needs both a
484    /// missed member and a kill in that window, and is strictly smaller than
485    /// the pre-report broadcast's exposure, but it is a real difference from
486    /// per-member fan-out, whose outbox entries persist for 7 days.
487    ///
488    /// Set `false` to force per-member fan-out even against a v2 relay —
489    /// the right choice for an app that must not lose a group message to a
490    /// mid-flight process kill.
491    /// Per-member fan-out (`__GRP_MLS_MSG__` as ordinary `SendMessage`
492    /// frames) inherits the full DM ladder end to end — outbox + ACK +
493    /// retry, relay write-ack + successor retry + offline push carrying the
494    /// ciphertext, park-on-unreachable with presence-driven flush, and
495    /// receiver-side deferred-ACK-after-decrypt — at the cost of O(N)
496    /// uplink frames per send.
497    ///
498    /// O(N) fan-out does **not** risk tripping the relay's rate limiter at
499    /// any group size. The platform bridge meters every relay-bound frame
500    /// through a client-side token bucket (`RelayRateLimiter`: 28 capacity,
501    /// 9/s refill) deliberately tighter than the relay's (30 burst, 10/s),
502    /// and a frame that cannot take a token is *deferred to a later poll
503    /// tick*, never dropped — so client spend stays at `28 + 9t` against a
504    /// server budget of `30 + 10t` and the fan-out self-paces below it
505    /// regardless of member count. Core enqueues all N frames at once; the
506    /// bridge drains that queue.
507    ///
508    /// What large groups actually cost on the per-member path is drain
509    /// latency. Frame N reaches the wire at roughly `(N - 28) / 9` seconds.
510    /// Since the ACK timer starts at local *enqueue* (`handle_send_success`)
511    /// and not at wire-confirm — `on_transport_send_confirmed` advances only
512    /// the welcome lifecycle — past roughly 118 members the tail of a single
513    /// fan-out exceeds the 10s ACK timeout and is retransmitted by the
514    /// ladder before it was ever written. That is wasted duplicate frames,
515    /// absorbed by receiver and push dedup via the stable outbox id, not
516    /// lost messages. Presence checks, typing, and receipts draw on the same
517    /// bucket and lower the threshold. This is the strongest reason to leave
518    /// the broadcast enabled for large groups.
519    pub relay_broadcast_enabled: bool,
520
521    /// Whether an incoming MLS membership commit is refused when the local
522    /// admin overlay does not authorize its committer.
523    ///
524    /// **Off by default, and turning it on is a decision about partition
525    /// risk, not a hardening toggle.** Refusing a commit means declining the
526    /// MLS merge: our epoch stays behind every member who did merge, and MLS
527    /// has no way back — the application has to re-invite us. Enforcement is
528    /// therefore fork-free only if every member reaches the *same* verdict,
529    /// and the admin overlay is replicated best-effort (role changes ride
530    /// unreconciled mesh notifications; a joiner gets a point-in-time
531    /// snapshot). Two members whose role maps merely disagree will partition
532    /// each other with no attacker involved.
533    ///
534    /// The check fails open on every *absent* input — no metadata, no admin
535    /// role stored, an unreadable roster — so the common lag case stays
536    /// harmless. It cannot detect *divergent* views, which is the residual
537    /// risk that keeps this opt-in. Enable it only for a closed deployment
538    /// that controls role distribution.
539    ///
540    /// With it off (the default), unauthorized membership changes are still
541    /// applied and reported: `group_unauthorized_membership_change` fires and
542    /// the roster events carry `authorized: false`. That reporting is
543    /// unaffected by this flag; the flag only decides whether the commit is
544    /// *also* refused.
545    ///
546    /// Rejection is receiver-local. The sender's message is still ACKed, so
547    /// a committer gets no signal that anyone refused, and members with the
548    /// flag off will apply a commit that members with it on rejected — do not
549    /// enable it on part of a fleet.
550    #[serde(default)]
551    pub enforce_admin_commits: bool,
552}
553
554impl Default for GroupConfig {
555    fn default() -> Self {
556        Self {
557            max_group_members: 256,
558            relay_enabled: true,
559            relay_broadcast_enabled: true,
560            enforce_admin_commits: false,
561        }
562    }
563}
564
565/// Main configuration for the Offline Protocol.
566#[derive(Debug, Clone)]
567pub struct ProtocolConfig {
568    /// Application identifier (required).
569    pub app_id: String,
570
571    /// Local profile selector (required).
572    ///
573    /// Selects which stored identity this instance runs as — one namespace per
574    /// `(app_id, profile)` pair — so an app hosting several accounts gives each
575    /// its own value, and an app hosting one can pass a constant like
576    /// `"default"`.
577    ///
578    /// It is **not** the device's identity. The wire identity is the
579    /// self-certifying address derived from the identity key held in this
580    /// profile's storage: it is minted on first start, reported by
581    /// [`crate::OfflineProtocol::local_address`], and it is what gets stamped
582    /// as `Message.sender`, what peers see as `NeighborDiscovered.peer_id`,
583    /// and what others use as `recipient`. An app cannot choose it, because
584    /// choosing it would mean claiming a key one does not hold.
585    ///
586    /// Under any configuration that reaches the wire the profile stays local.
587    /// The one exception is the explicit
588    /// [`EncryptionConfig::require_encryption`]` = false` opt-out, which lets a
589    /// plaintext send go out with MLS never initialized — no address has been
590    /// derived at that point, so the frame carries the profile as its sender
591    /// and the send emits
592    /// [`crate::events::SecurityWarningCode::PlaintextSend`]. The default
593    /// (`require_encryption = true`) fails such a send closed instead. Treat
594    /// the profile as private if that opt-out is in use.
595    pub profile: String,
596
597    /// Transport configuration.
598    pub transport: TransportConfig,
599
600    /// DORS (Dynamic Offline Relay Switch) configuration.
601    pub dors: DorsConfig,
602
603    /// Relay management configuration: whether this device carries traffic
604    /// for other people at all, and the battery floors under which it stops.
605    pub relay: RelayConfig,
606
607    /// How this device carries traffic for other people: how far frames
608    /// travel, how many neighbors each is handed to, and the ceilings that
609    /// keep forwarding from crowding the radio out.
610    ///
611    /// Whether to forward is [`Self::relay`]'s decision; this is the shape of
612    /// it once the answer is yes.
613    pub mesh_relay: MeshRelayConfig,
614
615    /// Path selection configuration.
616    pub path: PathConfig,
617
618    /// Reliability layer configuration.
619    pub reliability: ReliabilityConfig,
620
621    /// Encryption configuration for automatic MLS handling.
622    pub encryption: EncryptionConfig,
623
624    /// Initial TTL (Time-To-Live) for messages.
625    pub initial_ttl: u8,
626
627    /// Mesh group messaging configuration.
628    pub group: GroupConfig,
629
630    /// Security configuration for transport and control-message hardening.
631    pub security: SecurityConfig,
632}
633
634impl ProtocolConfig {
635    /// Creates a new protocol configuration.
636    ///
637    /// # Arguments
638    ///
639    /// * `app_id` - Application identifier
640    /// * `profile` - Local profile selector (storage namespace, never on the wire)
641    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
642        Self {
643            app_id: app_id.into(),
644            profile: profile.into(),
645            transport: TransportConfig::default(),
646            dors: DorsConfig::default(),
647            relay: RelayConfig::default(),
648            mesh_relay: MeshRelayConfig::default(),
649            path: PathConfig::default(),
650            reliability: ReliabilityConfig::default(),
651            encryption: EncryptionConfig::default(),
652            initial_ttl: DEFAULT_INITIAL_TTL,
653            group: GroupConfig::default(),
654            security: SecurityConfig::default(),
655        }
656    }
657
658    /// Creates a builder for more granular configuration.
659    pub fn builder(app_id: impl Into<String>, profile: impl Into<String>) -> ProtocolConfigBuilder {
660        ProtocolConfigBuilder::new(app_id, profile)
661    }
662
663    /// Validates the configuration.
664    ///
665    /// # Returns
666    ///
667    /// Returns `Ok(())` if valid, `Err` with a description of the problem if invalid.
668    pub fn validate(&self) -> crate::Result<()> {
669        // Both become storage keys — `app_id` and `profile` are the two inputs
670        // to the account namespace — so hold them to the same length cap and
671        // charset ban as every other identifier rather than only rejecting the
672        // empty string. Going through the core constructors keeps one policy
673        // instead of a second copy that can drift. Catching this here turns a
674        // later failure deep inside a storage backend into one clear
675        // configuration error.
676        offline_protocol_core::AppId::new(self.app_id.as_str())
677            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
678
679        offline_protocol_core::UserId::new(self.profile.as_str())
680            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
681
682        if self.initial_ttl == 0 {
683            return Err(crate::Error::InvalidConfiguration(
684                "initial_ttl must be greater than 0".to_string(),
685            ));
686        }
687
688        if !self.transport.ble_enabled
689            && !self.transport.wifi_direct_enabled
690            && !self.transport.internet_enabled
691            && !self.transport.reticulum_enabled
692            && !self.transport.nostr_enabled
693        {
694            return Err(crate::Error::InvalidConfiguration(
695                "At least one transport must be enabled".to_string(),
696            ));
697        }
698
699        if self.reliability.ack.default_timeout_ms == 0 {
700            return Err(crate::Error::InvalidConfiguration(
701                "ack.default_timeout_ms must be greater than 0".to_string(),
702            ));
703        }
704
705        if self.reliability.ack.max_pending_acks == 0 {
706            return Err(crate::Error::InvalidConfiguration(
707                "ack.max_pending_acks must be greater than 0".to_string(),
708            ));
709        }
710
711        if self.reliability.retry.initial_delay_ms == 0 {
712            return Err(crate::Error::InvalidConfiguration(
713                "retry.initial_delay_ms must be greater than 0".to_string(),
714            ));
715        }
716
717        if self.reliability.retry.max_delay_ms == 0 {
718            return Err(crate::Error::InvalidConfiguration(
719                "retry.max_delay_ms must be greater than 0".to_string(),
720            ));
721        }
722
723        if self.reliability.retry.initial_delay_ms > self.reliability.retry.max_delay_ms {
724            return Err(crate::Error::InvalidConfiguration(
725                "retry.initial_delay_ms must be <= retry.max_delay_ms".to_string(),
726            ));
727        }
728
729        if self.reliability.retry.outbox_max_lifetime_ms == 0
730            || self.reliability.retry.outbox_max_lifetime_ms > i64::MAX as u64
731        {
732            return Err(crate::Error::InvalidConfiguration(
733                "retry.outbox_max_lifetime_ms must be in 1..=i64::MAX".to_string(),
734            ));
735        }
736
737        if self.reliability.retry.pending_message_max_lifetime_ms == 0 {
738            return Err(crate::Error::InvalidConfiguration(
739                "retry.pending_message_max_lifetime_ms must be greater than 0".to_string(),
740            ));
741        }
742
743        if self.reliability.retry.pending_message_max_lifetime_ms > i64::MAX as u64 {
744            return Err(crate::Error::InvalidConfiguration(
745                "retry.pending_message_max_lifetime_ms must be <= i64::MAX".to_string(),
746            ));
747        }
748
749        if !self.reliability.retry.backoff_multiplier.is_finite()
750            || self.reliability.retry.backoff_multiplier < 1.0
751        {
752            return Err(crate::Error::InvalidConfiguration(
753                "retry.backoff_multiplier must be finite and >= 1.0".to_string(),
754            ));
755        }
756
757        // Checked outside the Bloom guard below, and that placement is the
758        // whole point: these two are the *only* dedup fields the UniFFI
759        // `DedupConfig` carries, so every constraint hidden behind
760        // `use_bloom_filter` is unreachable from a binding caller. A zero here
761        // is not a cosmetic problem — `Deduplicator` fails safe on the Bloom
762        // parameters, but nothing fails safe on these. At
763        // `max_tracked_messages == 0` the HashMap branch of `mark_seen` evicts
764        // on every insert, so the map holds one id and duplicate suppression is
765        // effectively off; `retention_time_secs == 0` expires every entry
766        // immediately for the same net result.
767        //
768        // What this rejects is the *degenerate* value, and that is the whole of
769        // the claim: a floor of 1 is not a floor on how well duplicates are
770        // suppressed. `max_tracked_messages == 1` behaves indistinguishably
771        // from `0`, and a two-second retention on a link whose retry backoff is
772        // longer than that suppresses nothing either. Choosing a window wide
773        // enough for the deployment is the application's call — the SDK refuses
774        // only the values that cannot be a choice, because nothing downstream
775        // spells them as a configuration error and the failure they produce
776        // (silently re-delivered messages) does not look like a config bug.
777        // Raising either floor to a policy minimum is a wider behavioural break
778        // and would need its own decision.
779        if self.reliability.dedup.max_tracked_messages == 0 {
780            return Err(crate::Error::InvalidConfiguration(
781                "reliability.dedup.max_tracked_messages must be greater than 0".to_string(),
782            ));
783        }
784
785        if self.reliability.dedup.retention_time_secs == 0 {
786            return Err(crate::Error::InvalidConfiguration(
787                "reliability.dedup.retention_time_secs must be greater than 0".to_string(),
788            ));
789        }
790
791        if self.reliability.dedup.use_bloom_filter {
792            if self.reliability.dedup.bloom_filter_bits == 0 {
793                return Err(crate::Error::InvalidConfiguration(
794                    "reliability.dedup.bloom_filter_bits must be greater than 0".to_string(),
795                ));
796            }
797
798            if self.reliability.dedup.bloom_hash_count == 0 {
799                return Err(crate::Error::InvalidConfiguration(
800                    "reliability.dedup.bloom_hash_count must be greater than 0".to_string(),
801                ));
802            }
803
804            if self.reliability.dedup.bloom_filter_count == 0 {
805                return Err(crate::Error::InvalidConfiguration(
806                    "reliability.dedup.bloom_filter_count must be greater than 0".to_string(),
807                ));
808            }
809
810            if self.reliability.dedup.bloom_rotation_secs == 0 {
811                return Err(crate::Error::InvalidConfiguration(
812                    "reliability.dedup.bloom_rotation_secs must be greater than 0".to_string(),
813                ));
814            }
815        }
816
817        if self.encryption.require_encryption && !self.encryption.enabled {
818            return Err(crate::Error::InvalidConfiguration(
819                "encryption.require_encryption requires encryption.enabled=true".to_string(),
820            ));
821        }
822
823        if self.encryption.pending_queue.max_pending_per_peer == 0 {
824            return Err(crate::Error::InvalidConfiguration(
825                "encryption.pending_queue.max_pending_per_peer must be greater than 0".to_string(),
826            ));
827        }
828
829        if self.encryption.pending_queue.max_pending_global == 0 {
830            return Err(crate::Error::InvalidConfiguration(
831                "encryption.pending_queue.max_pending_global must be greater than 0".to_string(),
832            ));
833        }
834
835        if self.encryption.pending_queue.max_pending_global
836            < self.encryption.pending_queue.max_pending_per_peer
837        {
838            return Err(crate::Error::InvalidConfiguration(
839                "encryption.pending_queue.max_pending_global must be >= max_pending_per_peer"
840                    .to_string(),
841            ));
842        }
843
844        if self.encryption.pending_queue.pending_ttl_ms == 0 {
845            return Err(crate::Error::InvalidConfiguration(
846                "encryption.pending_queue.pending_ttl_ms must be greater than 0".to_string(),
847            ));
848        }
849
850        if self.group.max_group_members == 0 {
851            return Err(crate::Error::InvalidConfiguration(
852                "group.max_group_members must be greater than 0".to_string(),
853            ));
854        }
855
856        // A fan-out of zero is not a cheaper forward, it is a silent drop: the
857        // frame has already been admitted and recorded as seen, so no copy goes
858        // out and this node's suppression entry stops it arriving again by
859        // another path. Same argument as `bias_min_scale` below — declining to
860        // carry other people's traffic is `relay.allow_relay`'s job, and it is
861        // visible. The forwarding paths clamp with `.max(1)`; rejecting zero
862        // here keeps that clamp an unreachable defensive floor instead of a
863        // silent rewrite of what zero means.
864        if self.mesh_relay.fanout == 0 {
865            return Err(crate::Error::InvalidConfiguration(
866                "mesh_relay.fanout must be greater than 0".to_string(),
867            ));
868        }
869
870        // The same argument as fan-out, for every other dial that can reach
871        // zero. Each of these reads like a conservative setting and is in fact
872        // an off switch that reports nothing: the failure is total, silent, and
873        // indistinguishable from a quiet neighborhood, which is what makes
874        // rejecting it at construction worth more than any runtime clamp.
875        //
876        // A hop ceiling of zero clamps every arriving budget to nothing, so
877        // `prepare_hop` refuses the frame before it is ever queued. Both
878        // ceilings are checked: the dense one applies only in a crowded room,
879        // so a zero there fails exactly where the mesh is most needed and
880        // nowhere else — the hardest version of this bug to reproduce.
881        if self.mesh_relay.max_ttl == 0 {
882            return Err(crate::Error::InvalidConfiguration(
883                "mesh_relay.max_ttl must be greater than 0".to_string(),
884            ));
885        }
886
887        if self.mesh_relay.dense_max_ttl == 0 {
888            return Err(crate::Error::InvalidConfiguration(
889                "mesh_relay.dense_max_ttl must be greater than 0".to_string(),
890            ));
891        }
892
893        // A queue that holds nothing refuses every admission: the length is
894        // always at or over capacity, and eviction has no victim to find, so
895        // each frame is rejected as queue-full. Nothing is forwarded and the
896        // only trace is `refused_queue_full`.
897        if self.mesh_relay.queue_capacity == 0 {
898            return Err(crate::Error::InvalidConfiguration(
899                "mesh_relay.queue_capacity must be greater than 0".to_string(),
900            ));
901        }
902
903        // The rate and burst dials feed token buckets that clamp their inputs
904        // with `.max(0.0)`. That clamp makes a negative or NaN value safe in
905        // the arithmetic sense and catastrophic in every other: the bucket
906        // simply never yields a token, so forwarding stops for good with no
907        // error and no counter. NaN matters on its own because `f32::max`
908        // returns the *other* operand for it, so a NaN rate silently becomes
909        // zero rather than propagating anywhere visible.
910        //
911        // Checked as a set: they are the same failure, and naming the field in
912        // the message is what makes a rejected config actionable.
913        for (name, value) in [
914            ("rate_per_sec", self.mesh_relay.rate_per_sec),
915            ("burst", self.mesh_relay.burst),
916            ("peer_rate_per_sec", self.mesh_relay.peer_rate_per_sec),
917            ("peer_burst", self.mesh_relay.peer_burst),
918        ] {
919            if !value.is_finite() || value <= 0.0 {
920                return Err(crate::Error::InvalidConfiguration(format!(
921                    "mesh_relay.{name} must be finite and greater than 0"
922                )));
923            }
924        }
925
926        // Capability bias scales forwarding effort; it must never be able to
927        // scale it to nothing. Zero would make a low-battery device stop
928        // forwarding by the back door, which is the partition the bias design
929        // exists to avoid — refusing to relay is `relay.allow_relay`'s job.
930        if !self.mesh_relay.bias_min_scale.is_finite()
931            || self.mesh_relay.bias_min_scale <= 0.0
932            || self.mesh_relay.bias_min_scale > 1.0
933        {
934            return Err(crate::Error::InvalidConfiguration(
935                "mesh_relay.bias_min_scale must be finite and in (0.0, 1.0]".to_string(),
936            ));
937        }
938
939        // An inverted jitter window is not a narrow window, it is two separate
940        // failures. `jitter_for` raises the effective maximum to meet the
941        // minimum, so the span collapses to a single millisecond and every
942        // neighbor picks essentially the same delay — losing the spread that
943        // stops a crowded room transmitting on top of itself, which is the
944        // whole reason the delay exists. And because the check below reads
945        // `jitter_max`, an oversized `jitter_min` would slip past the overdue
946        // cut-off entirely: a ten-minute minimum with the default maximum
947        // validates clean and abandons every forward.
948        if self.mesh_relay.jitter_min > self.mesh_relay.jitter_max {
949            return Err(crate::Error::InvalidConfiguration(format!(
950                "mesh_relay.jitter_min ({}ms) must not exceed mesh_relay.jitter_max ({}ms)",
951                self.mesh_relay.jitter_min.as_millis(),
952                self.mesh_relay.jitter_max.as_millis(),
953            )));
954        }
955
956        // A forward is abandoned rather than sent once it sits
957        // `RELAY_QUEUE_MAX_OVERDUE` past its due time, so the delay tunables
958        // must not be able to reach that on their own. Checked at the density
959        // the jitter span is configured for; the span also grows with neighbor
960        // count, which is a runtime quantity this cannot see and a documented
961        // ceiling of the mesh's venue-sized target rather than a config error.
962        let worst_delay = self
963            .mesh_relay
964            .jitter_max
965            .saturating_add(self.mesh_relay.bias_max_handicap);
966        if worst_delay >= RELAY_QUEUE_MAX_OVERDUE {
967            return Err(crate::Error::InvalidConfiguration(format!(
968                "mesh_relay.jitter_max + mesh_relay.bias_max_handicap ({}ms) must stay under \
969                 the {}ms overdue cut-off, or every biased forward is abandoned instead of sent",
970                worst_delay.as_millis(),
971                RELAY_QUEUE_MAX_OVERDUE.as_millis(),
972            )));
973        }
974
975        if self.mesh_relay.activity_window.is_zero() {
976            return Err(crate::Error::InvalidConfiguration(
977                "mesh_relay.activity_window must be greater than zero".to_string(),
978            ));
979        }
980
981        if self.mesh_relay.activity_min_forwards == 0 {
982            return Err(crate::Error::InvalidConfiguration(
983                "mesh_relay.activity_min_forwards must be greater than 0".to_string(),
984            ));
985        }
986
987        if self.mesh_relay.activity_idle_windows == 0 {
988            return Err(crate::Error::InvalidConfiguration(
989                "mesh_relay.activity_idle_windows must be greater than 0".to_string(),
990            ));
991        }
992
993        Ok(())
994    }
995}
996
997/// Builder for ProtocolConfig with a fluent API.
998pub struct ProtocolConfigBuilder {
999    config: ProtocolConfig,
1000}
1001
1002impl ProtocolConfigBuilder {
1003    /// Creates a new builder.
1004    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
1005        Self {
1006            config: ProtocolConfig::new(app_id, profile),
1007        }
1008    }
1009
1010    /// Configures transports.
1011    pub fn transport(mut self, config: TransportConfig) -> Self {
1012        self.config.transport = config;
1013        self
1014    }
1015
1016    /// Enables or disables BLE transport.
1017    pub fn ble_enabled(mut self, enabled: bool) -> Self {
1018        self.config.transport.ble_enabled = enabled;
1019        self
1020    }
1021
1022    /// Enables or disables Wi-Fi Direct transport.
1023    pub fn wifi_direct_enabled(mut self, enabled: bool) -> Self {
1024        self.config.transport.wifi_direct_enabled = enabled;
1025        self
1026    }
1027
1028    /// Enables or disables Internet transport.
1029    pub fn internet_enabled(mut self, enabled: bool) -> Self {
1030        self.config.transport.internet_enabled = enabled;
1031        self
1032    }
1033
1034    /// Enables or disables Nostr relay transport.
1035    pub fn nostr_enabled(mut self, enabled: bool) -> Self {
1036        self.config.transport.nostr_enabled = enabled;
1037        self
1038    }
1039
1040    /// Enables or disables sealing of outgoing Nostr frames (default on).
1041    pub fn nostr_sealing_enabled(mut self, enabled: bool) -> Self {
1042        self.config.transport.nostr_sealing_enabled = enabled;
1043        self
1044    }
1045
1046    /// Enables username discovery publication and resolution
1047    /// (default **off**; see
1048    /// [`TransportConfig::nostr_username_discovery_enabled`]).
1049    pub fn nostr_username_discovery_enabled(mut self, enabled: bool) -> Self {
1050        self.config.transport.nostr_username_discovery_enabled = enabled;
1051        self
1052    }
1053
1054    /// Enables or disables Nostr key-package publication and peer resolution
1055    /// (default on; see [`TransportConfig::nostr_cold_contact_enabled`]).
1056    pub fn nostr_cold_contact_enabled(mut self, enabled: bool) -> Self {
1057        self.config.transport.nostr_cold_contact_enabled = enabled;
1058        self
1059    }
1060
1061    /// Configures DORS (Dynamic Offline Relay Switch).
1062    pub fn dors(mut self, config: DorsConfig) -> Self {
1063        self.config.dors = config;
1064        self
1065    }
1066
1067    /// Enables online-first mode (prefer Internet when available).
1068    pub fn online_first(mut self, enabled: bool) -> Self {
1069        self.config.dors.prefer_online = enabled;
1070        self
1071    }
1072
1073    /// Configures relay management.
1074    pub fn relay(mut self, config: RelayConfig) -> Self {
1075        self.config.relay = config;
1076        self
1077    }
1078
1079    /// Configures how traffic is carried for other people.
1080    pub fn mesh_relay(mut self, config: MeshRelayConfig) -> Self {
1081        self.config.mesh_relay = config;
1082        self
1083    }
1084
1085    /// Configures path selection.
1086    pub fn path(mut self, config: PathConfig) -> Self {
1087        self.config.path = config;
1088        self
1089    }
1090
1091    /// Configures reliability layer.
1092    pub fn reliability(mut self, config: ReliabilityConfig) -> Self {
1093        self.config.reliability = config;
1094        self
1095    }
1096
1097    /// Sets the initial TTL for messages.
1098    pub fn initial_ttl(mut self, ttl: u8) -> Self {
1099        self.config.initial_ttl = ttl;
1100        self
1101    }
1102
1103    /// Configures encryption settings.
1104    pub fn encryption(mut self, config: EncryptionConfig) -> Self {
1105        self.config.encryption = config;
1106        self
1107    }
1108
1109    /// Enables or disables automatic encryption.
1110    pub fn encryption_enabled(mut self, enabled: bool) -> Self {
1111        self.config.encryption.enabled = enabled;
1112        self
1113    }
1114
1115    /// Enables or disables automatic key exchange on peer discovery.
1116    pub fn auto_key_exchange(mut self, enabled: bool) -> Self {
1117        self.config.encryption.auto_key_exchange = enabled;
1118        self
1119    }
1120
1121    /// Enables or disables storing pending messages for later encryption.
1122    pub fn store_pending_messages(mut self, enabled: bool) -> Self {
1123        self.config.encryption.store_pending = enabled;
1124        self
1125    }
1126
1127    /// Enables or disables strict encryption requirement.
1128    pub fn require_encryption(mut self, required: bool) -> Self {
1129        self.config.encryption.require_encryption = required;
1130        self
1131    }
1132
1133    /// Configures pending encrypted message queue bounds and overflow behavior.
1134    pub fn pending_queue(mut self, config: PendingQueueConfig) -> Self {
1135        self.config.encryption.pending_queue = config;
1136        self
1137    }
1138
1139    /// Configures group messaging settings.
1140    pub fn group(mut self, config: GroupConfig) -> Self {
1141        self.config.group = config;
1142        self
1143    }
1144
1145    /// Sets the maximum number of members allowed in a single group.
1146    pub fn max_group_members(mut self, max: usize) -> Self {
1147        self.config.group.max_group_members = max;
1148        self
1149    }
1150
1151    /// Sets whether groups should attempt relay server registration.
1152    pub fn group_relay_enabled(mut self, enabled: bool) -> Self {
1153        self.config.group.relay_enabled = enabled;
1154        self
1155    }
1156
1157    /// Sets whether relay-synced groups may send via a single relay broadcast
1158    /// instead of per-member fan-out. See
1159    /// [`GroupConfig::relay_broadcast_enabled`] — on by default, but only
1160    /// ever taken against a relay that advertised the `group_delivery_v3`
1161    /// capability, whose settled, address-space delivery report gives the
1162    /// broadcast a per-recipient delivery contract.
1163    pub fn group_relay_broadcast_enabled(mut self, enabled: bool) -> Self {
1164        self.config.group.relay_broadcast_enabled = enabled;
1165        self
1166    }
1167
1168    /// Sets whether unauthorized MLS membership commits are refused rather
1169    /// than applied-and-reported. See
1170    /// [`GroupConfig::enforce_admin_commits`] — off by default, and enabling
1171    /// it risks permanently forking members whose admin overlay disagrees.
1172    pub fn group_enforce_admin_commits(mut self, enabled: bool) -> Self {
1173        self.config.group.enforce_admin_commits = enabled;
1174        self
1175    }
1176
1177    /// Configures security settings.
1178    pub fn security(mut self, config: SecurityConfig) -> Self {
1179        self.config.security = config;
1180        self
1181    }
1182
1183    /// Sets whether transport identity is required for control messages.
1184    pub fn require_transport_identity(mut self, required: bool) -> Self {
1185        self.config.security.require_transport_identity = required;
1186        self
1187    }
1188
1189    /// Builds and validates the configuration.
1190    ///
1191    /// # Returns
1192    ///
1193    /// Returns `Ok(ProtocolConfig)` if valid, `Err` otherwise.
1194    pub fn build(self) -> crate::Result<ProtocolConfig> {
1195        self.config.validate()?;
1196        Ok(self.config)
1197    }
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203
1204    #[test]
1205    fn test_config_creation() {
1206        let config = ProtocolConfig::new("test-app", "user123");
1207        assert_eq!(config.app_id, "test-app");
1208        assert_eq!(config.profile, "user123");
1209        assert_eq!(config.initial_ttl, 8);
1210    }
1211
1212    #[test]
1213    fn test_config_validation_success() {
1214        let config = ProtocolConfig::new("test-app", "user123");
1215        assert!(config.validate().is_ok());
1216    }
1217
1218    #[test]
1219    fn test_config_validation_empty_app_id() {
1220        let config = ProtocolConfig::new("", "user123");
1221        assert!(config.validate().is_err());
1222    }
1223
1224    #[test]
1225    fn test_config_validation_empty_user_id() {
1226        let config = ProtocolConfig::new("test-app", "");
1227        assert!(config.validate().is_err());
1228    }
1229
1230    #[test]
1231    fn config_validation_enforces_the_id_length_cap() {
1232        // An unbounded id becomes an unbounded storage key and an unbounded
1233        // wire field; the cap is the same one the core newtypes apply.
1234        let at_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN);
1235        let over_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN + 1);
1236
1237        assert!(ProtocolConfig::new("test-app", &at_cap).validate().is_ok());
1238        assert!(ProtocolConfig::new(&at_cap, "user123").validate().is_ok());
1239        assert!(ProtocolConfig::new("test-app", &over_cap)
1240            .validate()
1241            .is_err());
1242        assert!(ProtocolConfig::new(&over_cap, "user123")
1243            .validate()
1244            .is_err());
1245    }
1246
1247    #[test]
1248    fn config_validation_enforces_the_id_charset() {
1249        // Path separators and the KV namespace separator would let an id
1250        // escape or collide with another account's storage keys.
1251        for hostile in ["a/b", "a\\b", "a:b", ".", "..", "a\u{0}b", "a\nb"] {
1252            assert!(
1253                ProtocolConfig::new("test-app", hostile).validate().is_err(),
1254                "user_id {hostile:?} must be rejected"
1255            );
1256            assert!(
1257                ProtocolConfig::new(hostile, "user123").validate().is_err(),
1258                "app_id {hostile:?} must be rejected"
1259            );
1260        }
1261    }
1262
1263    #[test]
1264    fn config_validation_accepts_an_address_shaped_id() {
1265        // The self-certifying address format must pass the id policy: the
1266        // bech32m charset is plain lowercase alphanumerics.
1267        let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
1268        assert!(ProtocolConfig::new("test-app", address).validate().is_ok());
1269    }
1270
1271    #[test]
1272    fn test_config_validation_zero_ttl() {
1273        let mut config = ProtocolConfig::new("test-app", "user123");
1274        config.initial_ttl = 0;
1275        assert!(config.validate().is_err());
1276    }
1277
1278    #[test]
1279    fn test_config_validation_no_transports() {
1280        let mut config = ProtocolConfig::new("test-app", "user123");
1281        config.transport.ble_enabled = false;
1282        config.transport.wifi_direct_enabled = false;
1283        config.transport.internet_enabled = false;
1284        assert!(config.validate().is_err());
1285    }
1286
1287    #[test]
1288    fn test_config_validation_retry_initial_delay_must_be_positive() {
1289        let mut config = ProtocolConfig::new("test-app", "user123");
1290        config.reliability.retry.initial_delay_ms = 0;
1291        assert!(config.validate().is_err());
1292    }
1293
1294    #[test]
1295    fn test_config_validation_retry_delay_bounds() {
1296        let mut config = ProtocolConfig::new("test-app", "user123");
1297        config.reliability.retry.initial_delay_ms = 2000;
1298        config.reliability.retry.max_delay_ms = 1000;
1299        assert!(config.validate().is_err());
1300    }
1301
1302    #[test]
1303    fn test_config_validation_retry_backoff_multiplier_bounds() {
1304        let mut config = ProtocolConfig::new("test-app", "user123");
1305        config.reliability.retry.backoff_multiplier = 0.5;
1306        assert!(config.validate().is_err());
1307    }
1308
1309    #[test]
1310    fn test_config_validation_rejects_zero_bloom_parameters() {
1311        let mutations: [fn(&mut DeduplicatorConfig); 4] = [
1312            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_bits = 0,
1313            |dedup: &mut DeduplicatorConfig| dedup.bloom_hash_count = 0,
1314            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_count = 0,
1315            |dedup: &mut DeduplicatorConfig| dedup.bloom_rotation_secs = 0,
1316        ];
1317
1318        for mutate in mutations {
1319            let mut config = ProtocolConfig::new("test-app", "user123");
1320            config.reliability.dedup.use_bloom_filter = true;
1321            mutate(&mut config.reliability.dedup);
1322            assert!(config.validate().is_err());
1323        }
1324    }
1325
1326    #[test]
1327    fn test_config_builder() {
1328        let config = ProtocolConfig::builder("test-app", "user123")
1329            .ble_enabled(true)
1330            .wifi_direct_enabled(false)
1331            .online_first(true)
1332            .initial_ttl(10)
1333            .build()
1334            .unwrap();
1335
1336        assert_eq!(config.app_id, "test-app");
1337        assert!(config.transport.ble_enabled);
1338        assert!(!config.transport.wifi_direct_enabled);
1339        assert!(config.dors.prefer_online);
1340        assert_eq!(config.initial_ttl, 10);
1341    }
1342
1343    #[test]
1344    fn test_transport_config_default() {
1345        let transport = TransportConfig::default();
1346        assert!(transport.ble_enabled);
1347        assert!(transport.wifi_direct_enabled);
1348        assert!(transport.internet_enabled);
1349    }
1350
1351    #[test]
1352    fn test_reliability_config_default() {
1353        let reliability = ReliabilityConfig::default();
1354        assert_eq!(reliability.ack.default_timeout_ms, 10000);
1355        assert_eq!(reliability.retry.max_retries, 10);
1356        // 7 days — must match the app-layer presence-flush window and the
1357        // fallback values hardcoded in the RN native bridges
1358        // (OfflineProtocolModule.kt / OfflineProtocolModule.swift).
1359        assert_eq!(reliability.retry.outbox_max_lifetime_ms, 604_800_000);
1360        // 5 min ceiling — also mirrored by the RN bridge fallbacks.
1361        assert_eq!(reliability.retry.max_delay_ms, 300_000);
1362        assert_eq!(reliability.dedup.max_tracked_messages, 1000);
1363    }
1364
1365    #[test]
1366    fn test_encryption_config_default() {
1367        let encryption = EncryptionConfig::default();
1368        assert!(encryption.enabled);
1369        assert!(encryption.auto_key_exchange);
1370        assert!(encryption.store_pending);
1371        // SEC-M3: encryption is required by default; plaintext is an
1372        // explicit opt-out, never a silent fallback.
1373        assert!(encryption.require_encryption);
1374        assert_eq!(encryption.pending_queue.max_pending_per_peer, 64);
1375        assert_eq!(encryption.pending_queue.max_pending_global, 4096);
1376        assert_eq!(
1377            encryption.pending_queue.pending_ttl_ms,
1378            DEFAULT_PENDING_TTL_MS
1379        );
1380        assert_eq!(
1381            encryption.pending_queue.overflow_policy,
1382            OverflowPolicy::DropOldest
1383        );
1384    }
1385
1386    #[test]
1387    fn test_encryption_config_disabled() {
1388        let encryption = EncryptionConfig::disabled();
1389        assert!(!encryption.enabled);
1390        assert!(!encryption.auto_key_exchange);
1391        assert!(!encryption.store_pending);
1392        assert!(!encryption.require_encryption);
1393    }
1394
1395    #[test]
1396    fn test_config_builder_with_encryption() {
1397        let config = ProtocolConfig::builder("test-app", "user123")
1398            .encryption_enabled(true)
1399            .auto_key_exchange(true)
1400            .store_pending_messages(false)
1401            .require_encryption(true)
1402            .pending_queue(PendingQueueConfig {
1403                max_pending_per_peer: 32,
1404                max_pending_global: 512,
1405                pending_ttl_ms: 30_000,
1406                overflow_policy: OverflowPolicy::DropNewest,
1407                ..Default::default()
1408            })
1409            .build()
1410            .unwrap();
1411
1412        assert!(config.encryption.enabled);
1413        assert!(config.encryption.auto_key_exchange);
1414        assert!(!config.encryption.store_pending);
1415        assert!(config.encryption.require_encryption);
1416        assert_eq!(config.encryption.pending_queue.max_pending_per_peer, 32);
1417        assert_eq!(config.encryption.pending_queue.max_pending_global, 512);
1418        assert_eq!(config.encryption.pending_queue.pending_ttl_ms, 30_000);
1419        assert_eq!(
1420            config.encryption.pending_queue.overflow_policy,
1421            OverflowPolicy::DropNewest
1422        );
1423    }
1424
1425    #[test]
1426    fn test_config_validation_require_encryption_requires_enabled() {
1427        let mut config = ProtocolConfig::new("test-app", "user123");
1428        config.encryption.enabled = false;
1429        config.encryption.require_encryption = true;
1430        assert!(config.validate().is_err());
1431    }
1432
1433    #[test]
1434    fn test_config_validation_pending_queue_requires_positive_limits() {
1435        let mut config = ProtocolConfig::new("test-app", "user123");
1436        config.encryption.pending_queue.max_pending_per_peer = 0;
1437        assert!(config.validate().is_err());
1438
1439        let mut config = ProtocolConfig::new("test-app", "user123");
1440        config.encryption.pending_queue.max_pending_global = 0;
1441        assert!(config.validate().is_err());
1442
1443        let mut config = ProtocolConfig::new("test-app", "user123");
1444        config.encryption.pending_queue.pending_ttl_ms = 0;
1445        assert!(config.validate().is_err());
1446    }
1447
1448    #[test]
1449    fn test_config_validation_pending_queue_global_must_cover_per_peer() {
1450        let mut config = ProtocolConfig::new("test-app", "user123");
1451        config.encryption.pending_queue.max_pending_per_peer = 100;
1452        config.encryption.pending_queue.max_pending_global = 99;
1453        assert!(config.validate().is_err());
1454    }
1455
1456    #[test]
1457    fn test_group_config_default() {
1458        let group = GroupConfig::default();
1459        assert_eq!(group.max_group_members, 256);
1460        // Registration and broadcast both default on. Registration must stay
1461        // on because invite links resolve against the relay group registry.
1462        // The broadcast default is safe because the flag alone never selects
1463        // the path: it is additionally gated on the relay advertising the
1464        // `group_delivery_v3` capability, whose settled, address-space
1465        // delivery report is what gives the broadcast a per-recipient
1466        // delivery contract.
1467        assert!(group.relay_enabled);
1468        assert!(group.relay_broadcast_enabled);
1469        // Commit enforcement must stay OFF by default. On, a member whose
1470        // admin overlay merely lagged would refuse a legitimate commit and
1471        // fork itself out of a healthy group with no attacker involved —
1472        // unrecoverable without an app-level re-invite. Flipping this default
1473        // is a fleet-wide partition decision, not a hardening tweak.
1474        assert!(!group.enforce_admin_commits);
1475    }
1476
1477    #[test]
1478    fn test_group_enforce_admin_commits_builder_opt_in() {
1479        let config = ProtocolConfig::builder("test-app", "user123")
1480            .group_enforce_admin_commits(true)
1481            .build()
1482            .unwrap();
1483        assert!(config.group.enforce_admin_commits);
1484        assert!(
1485            config.group.relay_enabled && config.group.relay_broadcast_enabled,
1486            "opting into enforcement must not disturb the relay flags"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_group_relay_broadcast_builder_opt_out() {
1492        let config = ProtocolConfig::builder("test-app", "user123")
1493            .group_relay_broadcast_enabled(false)
1494            .build()
1495            .unwrap();
1496        assert!(!config.group.relay_broadcast_enabled);
1497        assert!(
1498            config.group.relay_enabled,
1499            "opting out of the broadcast must not disturb registration"
1500        );
1501    }
1502
1503    #[test]
1504    fn test_config_has_default_group_config() {
1505        let config = ProtocolConfig::new("test-app", "user123");
1506        assert_eq!(config.group.max_group_members, 256);
1507    }
1508
1509    #[test]
1510    fn test_config_validation_zero_max_group_members() {
1511        let mut config = ProtocolConfig::new("test-app", "user123");
1512        config.group.max_group_members = 0;
1513        assert!(config.validate().is_err());
1514    }
1515
1516    #[test]
1517    fn test_config_validation_positive_max_group_members() {
1518        let mut config = ProtocolConfig::new("test-app", "user123");
1519        config.group.max_group_members = 1;
1520        assert!(config.validate().is_ok());
1521    }
1522
1523    /// A handicap large enough to outrun the overdue cut-off must be refused.
1524    ///
1525    /// Past that point a biased forward is not late, it is *abandoned* on
1526    /// release — so the tunable that exists to cost redundancy would be costing
1527    /// delivery, silently, on exactly the devices it is meant to spare.
1528    #[test]
1529    fn test_config_validation_rejects_a_handicap_past_the_overdue_cutoff() {
1530        let mut config = ProtocolConfig::new("test-app", "user123");
1531        config.mesh_relay.bias_max_handicap = RELAY_QUEUE_MAX_OVERDUE;
1532        assert!(config.validate().is_err());
1533
1534        // The jitter window counts toward the same budget, so a handicap that
1535        // fits on its own can still be refused alongside a wide window.
1536        config.mesh_relay.bias_max_handicap = std::time::Duration::from_millis(400);
1537        config.mesh_relay.jitter_max = RELAY_QUEUE_MAX_OVERDUE;
1538        assert!(config.validate().is_err());
1539
1540        // And the defaults must sit comfortably inside it.
1541        let defaults = ProtocolConfig::new("test-app", "user123");
1542        assert!(defaults.validate().is_ok());
1543    }
1544
1545    /// Bias must never be configurable into an off switch.
1546    ///
1547    /// A scale of zero would stop a low-battery device forwarding by the back
1548    /// door, which is the partition the whole bias design exists to avoid —
1549    /// declining to relay is `relay.allow_relay`'s job, and it is visible.
1550    #[test]
1551    fn test_config_validation_rejects_a_bias_scale_that_silences_forwarding() {
1552        for scale in [0.0, -0.5, 1.5, f32::NAN, f32::INFINITY] {
1553            let mut config = ProtocolConfig::new("test-app", "user123");
1554            config.mesh_relay.bias_min_scale = scale;
1555            assert!(
1556                config.validate().is_err(),
1557                "bias_min_scale of {scale} must be refused",
1558            );
1559        }
1560
1561        // 1.0 is the documented way to disable bias entirely, and must stay
1562        // accepted.
1563        let mut config = ProtocolConfig::new("test-app", "user123");
1564        config.mesh_relay.bias_min_scale = 1.0;
1565        assert!(config.validate().is_ok());
1566    }
1567
1568    /// A fan-out of zero must be refused rather than clamped.
1569    ///
1570    /// It reads like an off switch and is not one: the frame is already
1571    /// admitted and recorded as seen by the time fan-out is chosen, so zero
1572    /// targets is a silent drop that also suppresses the copy arriving by
1573    /// another path. The forwarding paths clamp it to one; this keeps that
1574    /// clamp a defensive floor rather than a quiet reinterpretation.
1575    #[test]
1576    fn test_config_validation_rejects_a_fanout_of_zero() {
1577        let mut config = ProtocolConfig::new("test-app", "user123");
1578        config.mesh_relay.fanout = 0;
1579        assert!(config.validate().is_err());
1580
1581        // One is a real setting — forward to a single neighbor — and must stay
1582        // accepted, or the check has swallowed the narrowest working fan-out.
1583        config.mesh_relay.fanout = 1;
1584        assert!(config.validate().is_ok());
1585    }
1586
1587    /// Every hop-budget, queue and rate dial that can silently switch
1588    /// forwarding off must be refused, not clamped.
1589    ///
1590    /// These share one failure and it is the worst kind: the device keeps
1591    /// running, reports no error, and carries nothing. Each value below is the
1592    /// one a caller would reach for meaning "be conservative", so leaving them
1593    /// legal makes an off switch out of a dial nobody thinks is one.
1594    #[test]
1595    fn test_config_validation_rejects_dials_that_silently_stop_forwarding() {
1596        // Both hop ceilings: zero clamps every arriving budget to nothing, so
1597        // the frame is refused before it can be queued. The dense one is
1598        // checked separately because it only bites in a crowded room.
1599        let mut zero_ttl = ProtocolConfig::new("test-app", "user123");
1600        zero_ttl.mesh_relay.max_ttl = 0;
1601        assert!(zero_ttl.validate().is_err());
1602
1603        let mut zero_dense_ttl = ProtocolConfig::new("test-app", "user123");
1604        zero_dense_ttl.mesh_relay.dense_max_ttl = 0;
1605        assert!(zero_dense_ttl.validate().is_err());
1606
1607        // A queue that holds nothing rejects every admission as queue-full.
1608        let mut zero_queue = ProtocolConfig::new("test-app", "user123");
1609        zero_queue.mesh_relay.queue_capacity = 0;
1610        assert!(zero_queue.validate().is_err());
1611
1612        // The token buckets clamp their inputs with `.max(0.0)`, which turns
1613        // each of these into a bucket that never yields a token. NaN is listed
1614        // on its own because `f32::max` returns the other operand for it, so it
1615        // arrives as zero rather than failing anywhere visible, and negatives
1616        // are what a bridge's own clamp hands over for validation here.
1617        for bad in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
1618            let mut rate = ProtocolConfig::new("test-app", "user123");
1619            rate.mesh_relay.rate_per_sec = bad;
1620            assert!(
1621                rate.validate().is_err(),
1622                "rate_per_sec of {bad} must be refused",
1623            );
1624
1625            let mut burst = ProtocolConfig::new("test-app", "user123");
1626            burst.mesh_relay.burst = bad;
1627            assert!(burst.validate().is_err(), "burst of {bad} must be refused");
1628
1629            let mut peer_rate = ProtocolConfig::new("test-app", "user123");
1630            peer_rate.mesh_relay.peer_rate_per_sec = bad;
1631            assert!(
1632                peer_rate.validate().is_err(),
1633                "peer_rate_per_sec of {bad} must be refused",
1634            );
1635
1636            let mut peer_burst = ProtocolConfig::new("test-app", "user123");
1637            peer_burst.mesh_relay.peer_burst = bad;
1638            assert!(
1639                peer_burst.validate().is_err(),
1640                "peer_burst of {bad} must be refused",
1641            );
1642        }
1643
1644        // The narrowest working settings must stay accepted, or the checks have
1645        // swallowed real configurations along with the broken ones.
1646        let mut minimal = ProtocolConfig::new("test-app", "user123");
1647        minimal.mesh_relay.max_ttl = 1;
1648        minimal.mesh_relay.dense_max_ttl = 1;
1649        minimal.mesh_relay.queue_capacity = 1;
1650        minimal.mesh_relay.rate_per_sec = 0.5;
1651        minimal.mesh_relay.burst = 1.0;
1652        minimal.mesh_relay.peer_rate_per_sec = 0.5;
1653        minimal.mesh_relay.peer_burst = 1.0;
1654        assert!(minimal.validate().is_ok());
1655    }
1656
1657    /// An inverted jitter window must be refused.
1658    ///
1659    /// Two failures in one, which is why the equal case has to stay legal to
1660    /// prove the check is an ordering check and not a ban on narrow windows.
1661    /// `jitter_for` raises the effective maximum to meet the minimum, so the
1662    /// span collapses to a millisecond and neighbors stop separating in time;
1663    /// and since the overdue check reads `jitter_max`, an oversized minimum
1664    /// would otherwise validate clean and abandon every forward.
1665    #[test]
1666    fn test_config_validation_rejects_an_inverted_jitter_window() {
1667        let mut inverted = ProtocolConfig::new("test-app", "user123");
1668        inverted.mesh_relay.jitter_min = std::time::Duration::from_millis(300);
1669        inverted.mesh_relay.jitter_max = std::time::Duration::from_millis(200);
1670        assert!(inverted.validate().is_err());
1671
1672        // The case the overdue check alone would have missed: a minimum past
1673        // the 5s cut-off, with the maximum left at its default.
1674        let mut oversized_min = ProtocolConfig::new("test-app", "user123");
1675        oversized_min.mesh_relay.jitter_min = std::time::Duration::from_secs(600);
1676        assert!(oversized_min.validate().is_err());
1677
1678        // Equal is a fixed delay, not an inversion, and stays accepted.
1679        let mut equal = ProtocolConfig::new("test-app", "user123");
1680        equal.mesh_relay.jitter_min = std::time::Duration::from_millis(200);
1681        equal.mesh_relay.jitter_max = std::time::Duration::from_millis(200);
1682        assert!(equal.validate().is_ok());
1683    }
1684
1685    /// The activity window's tunables decide whether the relay standing can be
1686    /// reported at all; a zero in any of them stalls or divides it.
1687    #[test]
1688    fn test_config_validation_rejects_zero_activity_tunables() {
1689        let mut zero_window = ProtocolConfig::new("test-app", "user123");
1690        zero_window.mesh_relay.activity_window = std::time::Duration::ZERO;
1691        assert!(zero_window.validate().is_err());
1692
1693        let mut zero_forwards = ProtocolConfig::new("test-app", "user123");
1694        zero_forwards.mesh_relay.activity_min_forwards = 0;
1695        assert!(zero_forwards.validate().is_err());
1696
1697        let mut zero_idle = ProtocolConfig::new("test-app", "user123");
1698        zero_idle.mesh_relay.activity_idle_windows = 0;
1699        assert!(zero_idle.validate().is_err());
1700    }
1701
1702    #[test]
1703    fn test_config_builder_max_group_members() {
1704        let config = ProtocolConfig::builder("test-app", "user123")
1705            .max_group_members(50)
1706            .build()
1707            .unwrap();
1708        assert_eq!(config.group.max_group_members, 50);
1709    }
1710
1711    #[test]
1712    fn test_config_builder_group_config() {
1713        let config = ProtocolConfig::builder("test-app", "user123")
1714            .group(GroupConfig {
1715                max_group_members: 128,
1716                ..Default::default()
1717            })
1718            .build()
1719            .unwrap();
1720        assert_eq!(config.group.max_group_members, 128);
1721    }
1722
1723    #[test]
1724    fn test_config_builder_zero_max_group_members_rejected() {
1725        let result = ProtocolConfig::builder("test-app", "user123")
1726            .max_group_members(0)
1727            .build();
1728        assert!(result.is_err());
1729    }
1730
1731    /// The RN bridges never reach [`DEFAULT_PENDING_TTL_MS`]: each layer
1732    /// substitutes its own fallback when the app omits `pendingTtlMs`, and the
1733    /// JS layer always materializes the field before it crosses the bridge. So
1734    /// the Rust default is only the RN default for as long as these three
1735    /// literals agree with it — they silently drifted to the pre-deferred-ACK
1736    /// 2-minute window once already, which is the window this constant was
1737    /// raised to replace.
1738    ///
1739    /// The sibling guard for the ACK/retry fallbacks lives in
1740    /// `offline-protocol-reliability`; this one has to live here because that
1741    /// crate cannot depend on this one.
1742    #[test]
1743    fn rn_bridge_pending_ttl_fallbacks_match_rust_default() {
1744        let rn_root =
1745            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native");
1746
1747        // Each entry is (path, the fallback that file must contain). The
1748        // pattern names the field it guards, not just the literal, so deleting
1749        // the fallback cannot be masked by the same number appearing on some
1750        // other line. Matched against the source with whitespace squeezed, so a
1751        // line wrap between the field and its `?:`/`??` doesn't hide it.
1752        let expected = [
1753            (
1754                rn_root.join("src/index.ts"),
1755                format!("pendingTtlMs ?? {DEFAULT_PENDING_TTL_MS}"),
1756            ),
1757            (
1758                rn_root.join("android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt"),
1759                format!(
1760                    "\"pending_ttl_ms\") ?: {}L",
1761                    format_underscored(DEFAULT_PENDING_TTL_MS)
1762                ),
1763            ),
1764            (
1765                rn_root.join("ios/EncryptionConfigReader.swift"),
1766                format!(
1767                    "\"pending_ttl_ms\") ?? {}",
1768                    format_underscored(DEFAULT_PENDING_TTL_MS)
1769                ),
1770            ),
1771        ];
1772
1773        // The guard only applies in the repo checkout; skip when the bindings
1774        // tree isn't present (e.g. a vendored crate). Read every file up front
1775        // so a moved or renamed bridge skips the whole guard rather than
1776        // silently dropping the checks that come after it.
1777        let Some(sources) = expected
1778            .iter()
1779            .map(|(path, _)| std::fs::read_to_string(path).ok())
1780            .collect::<Option<Vec<_>>>()
1781        else {
1782            eprintln!("bindings tree not present, skipping RN pending-TTL drift check");
1783            return;
1784        };
1785
1786        for ((path, fallback), source) in expected.iter().zip(&sources) {
1787            let squeezed = source.split_whitespace().collect::<Vec<_>>().join(" ");
1788            assert!(
1789                squeezed.contains(fallback.as_str()),
1790                "RN pending-queue TTL fallback drifted from DEFAULT_PENDING_TTL_MS: \
1791                 expected `{fallback}` in {}",
1792                path.display()
1793            );
1794        }
1795    }
1796
1797    /// Drift guard: every public surface that documents
1798    /// [`EncryptionConfig::crypto_recovery_enabled`] must state that the re-key
1799    /// trigger is unauthenticated, and must not carry the retired claim that it
1800    /// re-keys only on genuine peer traffic.
1801    ///
1802    /// This is guarded because it has already drifted once. The trigger was
1803    /// hardened and honestly documented in-code, but that pass corrected only
1804    /// `session.rs` and the internal architecture notes — six integrator-facing
1805    /// surfaces kept promising that an injected frame could never drive a
1806    /// re-key, and framed the residual as replay-only, while
1807    /// `test_forged_frame_reaches_session_desync_without_any_key_material` in
1808    /// `offline-protocol-mls` proved the opposite in the same workspace. Nothing
1809    /// else fails when these lag: the code stays correct and the docs quietly
1810    /// promise a guarantee it does not make, which is the worst shape for a
1811    /// security claim to fail in.
1812    #[test]
1813    fn crypto_recovery_docs_state_the_unauthenticated_trigger() {
1814        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1815        let surfaces = [
1816            "crates/offline-protocol/src/config.rs",
1817            "crates/offline-protocol-uniffi/src/offline_protocol.udl",
1818            "bindings/react-native/src/types.ts",
1819            "docs/mls-integration.md",
1820            "docs/configuration.md",
1821            "docs/api-reference.md",
1822        ];
1823
1824        // Retired claims. Each was false for the same reason: an MLS epoch is
1825        // read during framing validation, before the sender is authenticated.
1826        // Split across `concat!` so the banned phrases never appear verbatim in
1827        // this file — belt-and-braces on top of the `#[cfg(test)]` truncation
1828        // below, so the guard survives either defense being refactored away.
1829        let retired = [
1830            concat!("never re-keys on ", "injected garbage"),
1831            concat!("injected garbage ", "can't drive"),
1832            concat!("injecting ", "garbage"),
1833        ];
1834
1835        // The marker every surface must carry, concat-split for the inverse
1836        // reason: written verbatim, this needle alone would satisfy its own
1837        // check for config.rs no matter what the rustdoc says.
1838        let marker = concat!("re-key trigger is ", "unauthenticated");
1839
1840        for surface in surfaces {
1841            let path = root.join(surface);
1842            let source = std::fs::read_to_string(&path)
1843                .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
1844            // Scan only up to the first `#[cfg(test)]` token — for config.rs,
1845            // the non-test source. This test necessarily names the marker (doc
1846            // comment, panic message), so scanning its own module would satisfy
1847            // the marker check for the file it lives in even after the rustdoc
1848            // dropped it. The other surfaces don't contain the token and pass
1849            // through whole.
1850            let scanned = source.split("#[cfg(test)]").next().unwrap_or(&source);
1851            // Squeezed so a line wrap inside a phrase cannot hide it, and
1852            // lowercased so a sentence-initial capital cannot either. Standalone
1853            // comment/markup continuation tokens (`///`, `//`, `*`, `>`) are
1854            // dropped, because these six surfaces wrap prose in four different
1855            // comment syntaxes and a reflow would otherwise wedge one into the
1856            // middle of a phrase — a guard that fails on `cargo fmt` teaches
1857            // people to delete it.
1858            let squeezed = scanned
1859                .split_whitespace()
1860                .filter(|tok| !tok.chars().all(|c| matches!(c, '/' | '*' | '>')))
1861                .collect::<Vec<_>>()
1862                .join(" ")
1863                .to_lowercase();
1864
1865            for claim in retired {
1866                assert!(
1867                    !squeezed.contains(claim),
1868                    "{surface} carries the retired claim `{claim}`. The re-key \
1869                     trigger is reachable by anyone who can inject a frame — see \
1870                     test_forged_frame_reaches_session_desync_without_any_key_material"
1871                );
1872            }
1873
1874            assert!(
1875                squeezed.contains(marker),
1876                "{surface} documents crypto_recovery_enabled but no longer states \
1877                 that the re-key trigger is unauthenticated. Say so plainly, or an \
1878                 integrator will read a sustained SESSION_REKEY_TRIGGERED rate as a \
1879                 bug rather than as the injection signal it exists to surface."
1880            );
1881        }
1882    }
1883
1884    /// Renders `1800000` as `1_800_000`, the form Kotlin and Swift use.
1885    fn format_underscored(value: u64) -> String {
1886        let digits = value.to_string();
1887        let mut out = String::new();
1888        for (i, c) in digits.chars().enumerate() {
1889            if i > 0 && (digits.len() - i) % 3 == 0 {
1890                out.push('_');
1891            }
1892            out.push(c);
1893        }
1894        out
1895    }
1896}