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