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