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