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}