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