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