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