Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1449e6418c | ||
|
|
3a627a9599 | ||
|
|
d2d834ad16 | ||
|
|
437cdde4a5 | ||
|
|
eed1bf14c6 | ||
|
|
313e96513c | ||
|
|
c6e3a4d80f | ||
|
|
0e67647e90 | ||
|
|
f3408d9bb6 | ||
|
|
3ecb901e3e | ||
|
|
f6aca47604 | ||
|
|
50ae1e1c3e | ||
|
|
483ac674fb | ||
|
|
3dba9c2b1e | ||
|
|
8fdc0703bc | ||
|
|
d7b90b8415 | ||
|
|
9346b85c3f | ||
|
|
3c5ec8d9a1 | ||
|
|
d10e05b796 | ||
|
|
66cc06738e | ||
|
|
0f3b8a4a16 | ||
|
|
a1596ed234 | ||
|
|
89b81d151f | ||
|
|
3e5b654109 | ||
|
|
ca26c69937 | ||
|
|
58212c421a | ||
|
|
1000107259 | ||
|
|
6aadcf1f3f | ||
|
|
07604a146f | ||
|
|
4c782d3191 | ||
|
|
c4b687ba64 | ||
|
|
a7b43b1a3e | ||
|
|
d6df769799 | ||
|
|
5f73d1e629 | ||
|
|
70d1b99fb4 | ||
|
|
2ea98db9d8 | ||
|
|
bbe1c716e6 | ||
|
|
77ac452190 | ||
|
|
ea70575275 | ||
|
|
49e9ce1649 | ||
|
|
2ca440dd2d | ||
|
|
92a9579e69 | ||
|
|
cf9b7e5b6a | ||
|
|
3016b929b4 | ||
|
|
1ae3e8d0be | ||
|
|
1329fc726f | ||
|
|
234aeb81dd | ||
|
|
1dbc15b2e6 | ||
|
|
80aa56cbcc | ||
|
|
9cda20381f | ||
|
|
318ddf692a | ||
|
|
11a4859f8e | ||
|
|
a3d1a72eb0 | ||
|
|
17b71cf673 | ||
|
|
779222d1a7 | ||
|
|
538dcb6a4c | ||
|
|
98115b6db1 | ||
|
|
9854caca33 | ||
|
|
c8b551dcf7 | ||
|
|
99f40ae109 | ||
|
|
3a9381b966 | ||
|
|
54a2a6c905 | ||
|
|
7b7616ce7e | ||
|
|
b020a08ea0 | ||
|
|
2737e7d602 | ||
|
|
d3754b36bc | ||
|
|
112cd9d5f4 | ||
|
|
8a7991a376 | ||
|
|
6f4d0f5377 | ||
|
|
9cfdae3494 | ||
|
|
62183699db | ||
|
|
9be9a76b42 | ||
|
|
80f7be6dd7 | ||
|
|
83721240a4 | ||
|
|
6c66cf367a | ||
|
|
a137d01c90 | ||
|
|
bac6ea6e91 | ||
|
|
0c1030cf02 | ||
|
|
23aff6b0b1 | ||
|
|
3335cd5500 | ||
|
|
a4f049d8da | ||
|
|
8fea15245a | ||
|
|
42a2c1fc57 | ||
|
|
7e98b3103f | ||
|
|
2a61085f07 | ||
|
|
4386dd8b5a | ||
|
|
50ddd630be | ||
|
|
cb3250e7b4 | ||
|
|
0319addd2b | ||
|
|
77bf76e1f9 | ||
|
|
beafac1f73 | ||
|
|
a2d777bda0 | ||
|
|
e48bedeaf2 | ||
|
|
a2d35281b2 | ||
|
|
46035af9a3 | ||
|
|
4b7fc34fe3 | ||
|
|
96e8b4a146 | ||
|
|
2cedb66667 | ||
|
|
e345671c76 | ||
|
|
86fb2cddc5 | ||
|
|
931c533a3d | ||
|
|
79ba60e3ad | ||
|
|
fb477b24d7 | ||
|
|
9f263e8f3e | ||
|
|
db325cb81f | ||
|
|
b167d01f8a | ||
|
|
f4e7469f96 | ||
|
|
4647d69d4b | ||
|
|
9ab071d62c | ||
|
|
f4c09ac51f | ||
|
|
846be50f72 | ||
|
|
c0f357d817 | ||
|
|
40fc09a93d | ||
|
|
2a90a2c552 | ||
|
|
fc581bf729 | ||
|
|
b6ea025333 | ||
|
|
d3e2d9ac5b | ||
|
|
85a7fbf538 | ||
|
|
62733ef4c1 | ||
|
|
99e59b73a3 | ||
|
|
384a3352cf | ||
|
|
7a04fdff2e | ||
|
|
ba3c75e58c | ||
|
|
1062ccc5c3 | ||
|
|
36f05e272e | ||
|
|
660c4293f0 | ||
|
|
1b8613d767 | ||
|
|
bfa52c4ba5 | ||
|
|
c5eb66038b | ||
|
|
a46edd60f0 | ||
|
|
b4bcfd325b | ||
|
|
976bd3a389 | ||
|
|
bf27c846da | ||
|
|
455360205c | ||
|
|
79c67f2026 | ||
|
|
c8928626fc | ||
|
|
b47d28a22a | ||
|
|
c5b7d3c7af | ||
|
|
d950012530 | ||
|
|
27b1f48929 | ||
|
|
3d62a383d5 | ||
|
|
6ac7101f4f | ||
|
|
65cc19842c | ||
|
|
656f290660 | ||
|
|
643c3c3b3e | ||
|
|
da37384335 | ||
|
|
1658048c2c | ||
|
|
27d38518e1 | ||
|
|
cf8088ac6a | ||
|
|
1e82104224 | ||
|
|
46ff37c362 | ||
|
|
3df2425162 | ||
|
|
5241f5fe5e | ||
|
|
8e86c97a13 | ||
|
|
90e8c3adf6 | ||
|
|
56851365b1 | ||
|
|
a9814bb6d3 | ||
|
|
3ad8bd15a6 | ||
|
|
4c33d8ac43 | ||
|
|
a94ca62624 | ||
|
|
53b72469b6 | ||
|
|
f80ed32a06 | ||
|
|
07eaf9157b | ||
|
|
56ea2fdd56 | ||
|
|
ffecd4a17a | ||
|
|
dae649fb87 | ||
|
|
57a77f75c1 | ||
|
|
18e73b8aa7 | ||
|
|
af9ca59e51 | ||
|
|
d352d518c2 | ||
|
|
f0dc600016 | ||
|
|
f44ea0a6d8 | ||
|
|
f7d31d4c02 | ||
|
|
b90e25a3a5 | ||
|
|
cf4b9f669d | ||
|
|
e417d35cce | ||
|
|
deaec3cce2 | ||
|
|
7bbd99644a | ||
|
|
cb59a449dd | ||
|
|
a632eea75b | ||
|
|
3d10c9bf9e | ||
|
|
2f0cdc40af | ||
|
|
0a3d014f5d | ||
|
|
7d0115daec | ||
|
|
f024ab1c3f | ||
|
|
f4bc1f0926 | ||
|
|
42dbb887f7 | ||
|
|
850d2fa423 | ||
|
|
08b84deba4 | ||
|
|
ec5a60430d | ||
|
|
e4d4b51c59 | ||
|
|
6b60f8bab6 | ||
|
|
aa9b93eee5 | ||
|
|
afb338a41a | ||
|
|
1e29f7c811 | ||
|
|
d11be15295 | ||
|
|
305f79d2a6 | ||
|
|
af988f774e | ||
|
|
82e389298a | ||
|
|
c784d5ce0c | ||
|
|
c2c4730953 | ||
|
|
ccdd8e1775 | ||
|
|
307a784174 | ||
|
|
9abc0fa88d | ||
|
|
76aad61f00 | ||
|
|
37ce109009 | ||
|
|
a66163dc46 | ||
|
|
bdf8bf093c | ||
|
|
b7a9542213 | ||
|
|
3a3f9fd51e | ||
|
|
6dc9d76ed3 | ||
|
|
a2a4b80af4 | ||
|
|
7ec0ae3bb5 | ||
|
|
2c5530c9f0 | ||
|
|
94ee3ceced | ||
|
|
dd67de4d71 | ||
|
|
285fd93c32 | ||
|
|
56bec06856 | ||
|
|
04cbed90fb | ||
|
|
c1a6647f19 | ||
|
|
1dd677eec2 | ||
|
|
7636bc7491 | ||
|
|
79adb83782 | ||
|
|
b83d897650 | ||
|
|
0c3b25706a | ||
|
|
c3f57beafd | ||
|
|
aace3fd2fb | ||
|
|
2e67ed9c4c | ||
|
|
614fa2b163 | ||
|
|
903e96bdfa | ||
|
|
5c0adcbbff | ||
|
|
b2f16eeff4 | ||
|
|
6db79808aa | ||
|
|
f4b55bfc0c | ||
|
|
fe64d42db1 | ||
|
|
1b5a9f3b0c | ||
|
|
fdf8bd7ee2 | ||
|
|
019a32a623 | ||
|
|
d44f09d111 | ||
|
|
6b5de2c51b | ||
|
|
9410fb6f16 | ||
|
|
7b42aaa132 | ||
|
|
e4b9aa9746 | ||
|
|
aa26a55df4 | ||
|
|
6e505f5c0b | ||
|
|
a491a1d878 | ||
|
|
36f5539b9a | ||
|
|
1a2afe8bfd | ||
|
|
9fece9ce3d | ||
|
|
2e2ede5968 | ||
|
|
205d4ead1c | ||
|
|
80a070a8ea | ||
|
|
172540984c | ||
|
|
4dc40bd795 | ||
|
|
f9d7b1eebc | ||
|
|
ffbc733d58 | ||
|
|
2286726d72 | ||
|
|
1b8110a332 | ||
|
|
dad7e1b60c | ||
|
|
72c2ddcf57 | ||
|
|
a75e138965 | ||
|
|
fa319d798a | ||
|
|
c192339af9 | ||
|
|
fa67d128d1 | ||
|
|
55ff581655 | ||
|
|
560b257af7 | ||
|
|
7ac80bffcb | ||
|
|
0d8a1a2879 | ||
|
|
bd97cb0449 | ||
|
|
a58d3540ad | ||
|
|
5dbe6337ea | ||
|
|
b633f89c1a | ||
|
|
944a22a2fb | ||
|
|
ce24f83b64 | ||
|
|
45373e7f1f | ||
|
|
efe71cd598 | ||
|
|
ae41717b9b | ||
|
|
e78ab60c97 | ||
|
|
b3c8358a43 | ||
|
|
be81fe1f74 | ||
|
|
817f5e1737 | ||
|
|
eda9e36c82 | ||
|
|
2318975375 | ||
|
|
298666380f | ||
|
|
6a9075141f | ||
|
|
488c7082f3 | ||
|
|
fdde9863cd | ||
|
|
9404a3ab63 | ||
|
|
dadf6ec5b6 | ||
|
|
c528ea4f31 | ||
|
|
3d4e0b06c0 | ||
|
|
9d9ec8cd0b | ||
|
|
992b42e922 | ||
|
|
3cf9dacbc0 | ||
|
|
cfde5932fd | ||
|
|
69fb9a9e0e | ||
|
|
0a118f5631 | ||
|
|
0b986c1158 | ||
|
|
184a95cd2a | ||
|
|
b7a8df40c4 | ||
|
|
bf28902327 | ||
|
|
426bf68763 | ||
|
|
e557dc86e7 | ||
|
|
f5cef2556b | ||
|
|
cf66aadc20 | ||
|
|
77a5ed2ec6 | ||
|
|
4d640c16aa | ||
|
|
0a827307d6 | ||
|
|
64149599de | ||
|
|
07be469f49 | ||
|
|
79e9447b73 | ||
|
|
9893935e7c | ||
|
|
966b9990e0 | ||
|
|
bef1e38295 | ||
|
|
4b43cb8054 | ||
|
|
38ad3c046e | ||
|
|
b2afe6f40c | ||
|
|
601620bdde | ||
|
|
97104988a3 | ||
|
|
45333bd340 | ||
|
|
bbcb1abfda | ||
|
|
a64c1aa2c4 | ||
|
|
21fbbf9106 | ||
|
|
17a0be10fa | ||
|
|
3017d14392 | ||
|
|
d3275cef48 | ||
|
|
0fd4aa1e23 | ||
|
|
7c21945569 | ||
|
|
ffecf200d4 | ||
|
|
58d3c160ef | ||
|
|
326cf08efa | ||
|
|
d49dc201c3 | ||
|
|
6ff4c3f1ce | ||
|
|
5940514f40 | ||
|
|
916150742e | ||
|
|
1735b619e8 | ||
|
|
6b61d101dc | ||
|
|
56ca7bf30e | ||
|
|
2a218675b2 | ||
|
|
8b9eb49b57 | ||
|
|
3860341584 | ||
|
|
9078118a6d | ||
|
|
a7f5297ad1 | ||
|
|
870a60fc5d | ||
|
|
a8471eed5b | ||
|
|
a3351ad060 | ||
|
|
2322f5bf59 | ||
|
|
c9c075fd49 | ||
|
|
1484523164 | ||
|
|
dc17402896 | ||
|
|
0414ea59f0 | ||
|
|
967afaf802 | ||
|
|
77ee9bea38 | ||
|
|
e0cd28a65a | ||
|
|
7b6c86c6c1 | ||
|
|
1d0a418011 | ||
|
|
9253f5647a | ||
|
|
c41bfe831d | ||
|
|
c9af7e327e | ||
|
|
8870442830 | ||
|
|
9ad26c97a1 | ||
|
|
e1201f0967 | ||
|
|
ca2202e3c8 | ||
|
|
f24ee70138 | ||
|
|
8981cae3c2 | ||
|
|
b8971a31bc | ||
|
|
e01398a31a | ||
|
|
f871f20172 | ||
|
|
80d3518d55 | ||
|
|
49935473ae | ||
|
|
0705d05078 | ||
|
|
97d45fb2a6 | ||
|
|
50e4abf836 | ||
|
|
b130ea439a | ||
|
|
0e921d3258 | ||
|
|
d0f65329fb | ||
|
|
61b2b9df1a | ||
|
|
04e4a37d85 | ||
|
|
0ba3e69872 | ||
|
|
7488923fc3 | ||
|
|
cf559ee43f | ||
|
|
2ab0c2a7c6 | ||
|
|
243548631a | ||
|
|
da79d544cf | ||
|
|
e808825c95 | ||
|
|
a9576935cf | ||
|
|
9cf6c97137 | ||
|
|
0739f7b702 | ||
|
|
e12710fa55 | ||
|
|
2a120dfe57 | ||
|
|
3c18c4b66a | ||
|
|
21eee2b548 | ||
|
|
03168b9fbf | ||
|
|
41e1b2bd39 | ||
|
|
1afa0206a5 | ||
|
|
81fcdf8ac9 | ||
|
|
1f67b403de | ||
|
|
8e7f3f7b27 | ||
|
|
e6e9f1d5a5 | ||
|
|
4268b90763 | ||
|
|
856137b763 | ||
|
|
d0ee505c3a | ||
|
|
7dcac19a67 | ||
|
|
22260cd719 | ||
|
|
bef11e4cbe | ||
|
|
d5a65656f5 | ||
|
|
309ef3766c | ||
|
|
bde7b97f6a | ||
|
|
398dbeedea | ||
|
|
fa772af6c6 | ||
|
|
9ebdd2b0ea | ||
|
|
bec4e5e038 | ||
|
|
ea67b34ba7 | ||
|
|
e247f691cb | ||
|
|
896b2425a4 | ||
|
|
30e15b881f | ||
|
|
24ded9e5a0 | ||
|
|
f5e666ae4f | ||
|
|
62133ceb24 | ||
|
|
3b50e1fb7d | ||
|
|
7c25b3b1e0 | ||
|
|
a70bd02c67 | ||
|
|
990dcec348 | ||
|
|
75712641ee | ||
|
|
946e16339e | ||
|
|
092ed088ca | ||
|
|
7000431249 | ||
|
|
dab1980d79 | ||
|
|
344a295845 | ||
|
|
67d38090ed | ||
|
|
a9e8d02593 | ||
|
|
d859f27999 | ||
|
|
e86338d555 | ||
|
|
137ebc1cad | ||
|
|
d84a49b85a | ||
|
|
d5efe3406a | ||
|
|
d559a66145 | ||
|
|
ce6777b68d | ||
|
|
655e7ad7d5 | ||
|
|
9596a03139 | ||
|
|
2e7e476f81 | ||
|
|
2c9ad74599 | ||
|
|
aa60bd6770 | ||
|
|
16cf5c8628 | ||
|
|
56986f82c8 | ||
|
|
53e829a21c | ||
|
|
ab4acadcc1 | ||
|
|
325e679bc4 | ||
|
|
d81cae712e | ||
|
|
1d22608f86 | ||
|
|
39f3cf3082 | ||
|
|
5be81c562f | ||
|
|
2bdda1ff1f | ||
|
|
ac930dff30 | ||
|
|
1cbf8c0015 | ||
|
|
571d148bdf | ||
|
|
32a150eb40 | ||
|
|
5ad6e5f2f3 | ||
|
|
f229e135e3 | ||
|
|
d15d87c111 | ||
|
|
0dd52dc69f | ||
|
|
47dd0c4c64 | ||
|
|
7762b4d364 | ||
|
|
7d8457263b | ||
|
|
fc6b9c0e1b | ||
|
|
a76e15fd77 | ||
|
|
a017047c52 | ||
|
|
f4d1a33ed8 | ||
|
|
03ea151be5 | ||
|
|
83f8622b23 | ||
|
|
3753379ae4 | ||
|
|
2de16cfae8 | ||
|
|
5fa3c149e9 | ||
|
|
9093c7b120 | ||
|
|
47ae921b86 | ||
|
|
c605156302 | ||
|
|
051bf38bc7 | ||
|
|
8423c636a8 | ||
|
|
cc5732f41f | ||
|
|
7565ddd91c | ||
|
|
0de596dbcf | ||
|
|
e59a098e66 | ||
|
|
8d56b0ba85 | ||
|
|
b40d55c358 | ||
|
|
784d36d691 | ||
|
|
1bdfc8ae00 | ||
|
|
291039159f | ||
|
|
d4fa0af350 | ||
|
|
492d2f86bc | ||
|
|
8dd54ced0e | ||
|
|
e56bd0b47b | ||
|
|
ff2b00a029 | ||
|
|
017469b2de | ||
|
|
26979ff6b5 | ||
|
|
21abc5cabe | ||
|
|
6e76221e07 | ||
|
|
743676421d | ||
|
|
9d0774a58e | ||
|
|
ce732c0efb | ||
|
|
1ac0068d22 | ||
|
|
ae8e1c5e9a | ||
|
|
f392ce0a93 | ||
|
|
aa1d8d6c8b | ||
|
|
ae4325d0e7 | ||
|
|
da5f669133 | ||
|
|
5c839b40c9 | ||
|
|
a50e2e74a5 | ||
|
|
26cfad5e88 | ||
|
|
a99ce6122c | ||
|
|
a6b542b301 | ||
|
|
6a54a58a0d | ||
|
|
9ee684b0f4 | ||
|
|
c788692687 | ||
|
|
9e7e0d102d | ||
|
|
d47c099ac9 | ||
|
|
e371a4a62d | ||
|
|
8a3b72458f | ||
|
|
f616cb7b3e | ||
|
|
4bbadffa37 | ||
|
|
388d77b61a | ||
|
|
6597f6f0ed | ||
|
|
7008b08031 | ||
|
|
bb1d31e459 | ||
|
|
369efc24c2 | ||
|
|
da620c388a | ||
|
|
3c0cd7efcc | ||
|
|
3d605310ed | ||
|
|
3dab330480 | ||
|
|
44b8f265ae | ||
|
|
2e4f507919 | ||
|
|
d28b17e787 | ||
|
|
e1017e2a7f | ||
|
|
1ff91d888f | ||
|
|
0b5ab3a374 | ||
|
|
8ed54b33e0 | ||
|
|
3a3a84c58c | ||
|
|
d12ea92055 | ||
|
|
6e4067dc0c | ||
|
|
88f1b727e0 | ||
|
|
97a953550f | ||
|
|
39b3b5a25e | ||
|
|
15f0bb9e79 | ||
|
|
5c2c88315a | ||
|
|
0f663bd569 | ||
|
|
63f5e76f9c | ||
|
|
ee052a454e | ||
|
|
e55ec07fe2 | ||
|
|
199f85ec62 | ||
|
|
4a96dbf871 | ||
|
|
c8b2bb2ae1 | ||
|
|
ab7b350521 | ||
|
|
df35c50b99 | ||
|
|
bd67844169 | ||
|
|
470dc74391 | ||
|
|
2ff5f5a832 | ||
|
|
e7a73a4c89 | ||
|
|
6309f92c68 | ||
|
|
875a56b75a | ||
|
|
984394d906 | ||
|
|
5d38ad03af | ||
|
|
f6bb105536 | ||
|
|
6c86aa302f | ||
|
|
2b09ada392 | ||
|
|
8097df5540 | ||
|
|
5bff2f5537 | ||
|
|
769e54db76 | ||
|
|
f50589b31a | ||
|
|
2f2a3acdc0 | ||
|
|
f50fa5c60b | ||
|
|
e3f5d244c1 | ||
|
|
f89d715a99 | ||
|
|
8fe18c4f6d | ||
|
|
2e5433676e | ||
|
|
8b21f7ef2a | ||
|
|
7a15ea59c9 | ||
|
|
1bb6a5c43b | ||
|
|
1116aeeea1 | ||
|
|
077ca9c046 | ||
|
|
33f6d2f1da | ||
|
|
b33e8f4370 | ||
|
|
426f47c805 | ||
|
|
9af3297997 | ||
|
|
16d79da73e | ||
|
|
6d19033375 | ||
|
|
2d2cc86bd8 | ||
|
|
f51cf1deac | ||
|
|
00a2dde727 | ||
|
|
762909ce33 | ||
|
|
512afa1e2b | ||
|
|
c4c983348f | ||
|
|
969951d963 | ||
|
|
4455b8bd0e | ||
|
|
66c916dbb3 | ||
|
|
91cc48aabe | ||
|
|
1339a8da11 | ||
|
|
103ae06d55 | ||
|
|
8516b0dd5b | ||
|
|
9f72b249b4 | ||
|
|
acc7faea11 | ||
|
|
727fd8b8f5 | ||
|
|
dc111437fd | ||
|
|
b7afb725fd | ||
|
|
b48215d341 | ||
|
|
5cafd9d5c4 | ||
|
|
817fd77332 | ||
|
|
b27f126bab | ||
|
|
ea21b296fd | ||
|
|
3f57d54832 | ||
|
|
b40654c95d | ||
|
|
6d471b8be2 | ||
|
|
7a07076277 | ||
|
|
a70f492f4d | ||
|
|
c85f6a2f0e | ||
|
|
f22daf5cb6 | ||
|
|
93fd424f86 | ||
|
|
e13b0df539 | ||
|
|
ee8e4067b0 | ||
|
|
0e205e70ad | ||
|
|
b586a6e90a | ||
|
|
259f6fcc3b | ||
|
|
8d8010a62a | ||
|
|
c5941b790b | ||
|
|
60403955c0 | ||
|
|
9e49c289b4 | ||
|
|
6f83273318 | ||
|
|
fb5d54e0a2 | ||
|
|
0d2c1d3c01 | ||
|
|
6d60de2429 | ||
|
|
323cdc412e | ||
|
|
66eccbecb3 | ||
|
|
935d2e25df | ||
|
|
b931c1be37 | ||
|
|
daa866f333 | ||
|
|
fe28e02be7 | ||
|
|
1612a7e2c7 | ||
|
|
b08314722d | ||
|
|
2347b82b47 | ||
|
|
3d40393ae8 | ||
|
|
b98fdb672d | ||
|
|
3ba3172aaf | ||
|
|
2d1609f222 | ||
|
|
58ede38bf4 | ||
|
|
7712070cb3 | ||
|
|
c00c223f3b | ||
|
|
a3b229f668 | ||
|
|
8d7e0b1d09 | ||
|
|
2f43b43225 |
@@ -0,0 +1,44 @@
|
||||
name: 'Set Swap Space'
|
||||
description: 'Add moar swap'
|
||||
branding:
|
||||
icon: 'crop'
|
||||
color: 'orange'
|
||||
inputs:
|
||||
swap-size-gb:
|
||||
description: 'Swap space to create, in Gigabytes.'
|
||||
required: false
|
||||
default: '10'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Swap space report before modification
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Memory and swap:"
|
||||
free -h
|
||||
echo
|
||||
swapon --show
|
||||
echo
|
||||
- name: Set Swap
|
||||
shell: bash
|
||||
run: |
|
||||
export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
|
||||
echo "Swap file: $SWAP_FILE"
|
||||
if [ -z "$SWAP_FILE" ]; then
|
||||
SWAP_FILE=/opt/swapfile
|
||||
else
|
||||
sudo swapoff $SWAP_FILE
|
||||
sudo rm $SWAP_FILE
|
||||
fi
|
||||
sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
|
||||
sudo chmod 600 $SWAP_FILE
|
||||
sudo mkswap $SWAP_FILE
|
||||
sudo swapon $SWAP_FILE
|
||||
- name: Swap space report after modification
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Memory and swap:"
|
||||
free -h
|
||||
echo
|
||||
swapon --show
|
||||
echo
|
||||
@@ -10,62 +10,25 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-${{ matrix.os }}-${{ matrix.ghc }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
platform_name: 20_04-x86-64
|
||||
ghc: "8.10.7"
|
||||
- os: ubuntu-20.04
|
||||
platform_name: 20_04-x86-64
|
||||
ghc: "9.6.3"
|
||||
- os: ubuntu-22.04
|
||||
platform_name: 22_04-x86-64
|
||||
ghc: "9.6.3"
|
||||
|
||||
# =============================
|
||||
# Create release
|
||||
# =============================
|
||||
|
||||
# Create release, but only if it's triggered by tag push.
|
||||
# On pull requests/commits push, this job will always complete.
|
||||
|
||||
maybe-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone project
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: haskell-actions/setup@v2
|
||||
with:
|
||||
ghc-version: ${{ matrix.ghc }}
|
||||
cabal-version: "3.10.1.0"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cabal/store
|
||||
dist-newstyle
|
||||
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplexmq.cabal') }}
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: cabal build --enable-tests
|
||||
|
||||
- name: Test
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
|
||||
- name: Prepare binaries
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
run: |
|
||||
mv $(cabal list-bin smp-server) smp-server-ubuntu-${{ matrix.platform_name}}
|
||||
mv $(cabal list-bin ntf-server) ntf-server-ubuntu-${{ matrix.platform_name}}
|
||||
mv $(cabal list-bin xftp-server) xftp-server-ubuntu-${{ matrix.platform_name}}
|
||||
mv $(cabal list-bin xftp) xftp-ubuntu-${{ matrix.platform_name}}
|
||||
|
||||
- name: Build changelog
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v1
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: simplex-chat/release-changelog-builder-action@v5
|
||||
with:
|
||||
configuration: .github/changelog_conf.json
|
||||
failOnError: true
|
||||
@@ -75,8 +38,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04' && matrix.ghc == '9.6.3'
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: simplex-chat/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
See full changelog [here](https://github.com/simplex-chat/simplexmq/blob/master/CHANGELOG.md).
|
||||
@@ -86,10 +49,241 @@ jobs:
|
||||
prerelease: true
|
||||
files: |
|
||||
LICENSE
|
||||
smp-server-ubuntu-${{ matrix.platform_name}}
|
||||
ntf-server-ubuntu-${{ matrix.platform_name}}
|
||||
xftp-server-ubuntu-${{ matrix.platform_name}}
|
||||
xftp-ubuntu-${{ matrix.platform_name}}
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# =============================
|
||||
# Main build job
|
||||
# =============================
|
||||
|
||||
build:
|
||||
name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }}, GHC: ${{ matrix.ghc }}"
|
||||
needs: maybe-release
|
||||
env:
|
||||
apps: "smp-server xftp-server ntf-server xftp"
|
||||
runs-on: ${{ matrix.runner }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust # Allows passwordless access
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
# Maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: x86-64
|
||||
runner: "ubuntu-22.04"
|
||||
ghc: "8.10.7"
|
||||
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: x86-64
|
||||
runner: "ubuntu-22.04"
|
||||
ghc: "9.6.3"
|
||||
should_run: true
|
||||
- os: 24.04
|
||||
os_underscore: 24_04
|
||||
arch: x86-64
|
||||
runner: "ubuntu-24.04"
|
||||
ghc: "9.6.3"
|
||||
should_run: true
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: aarch64
|
||||
runner: "ubuntu-22.04-arm"
|
||||
ghc: "9.6.3"
|
||||
should_run: true
|
||||
- os: 24.04
|
||||
os_underscore: 24_04
|
||||
arch: aarch64
|
||||
runner: "ubuntu-24.04-arm"
|
||||
ghc: "9.6.3"
|
||||
should_run: true
|
||||
steps:
|
||||
- name: Clone project
|
||||
if: matrix.should_run == true
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: matrix.should_run == true
|
||||
uses: simplex-chat/docker-setup-buildx-action@v3
|
||||
|
||||
- name: Setup swap
|
||||
if: matrix.ghc == '8.10.7' && matrix.should_run == true
|
||||
uses: ./.github/actions/swap
|
||||
with:
|
||||
swap-size-gb: 20
|
||||
|
||||
- name: Install PostgreSQL 15 client tools
|
||||
if: matrix.os == '22.04' && matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
# Import the repository signing key
|
||||
sudo install -d /usr/share/postgresql-common/pgdg
|
||||
sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
|
||||
# Add the PostgreSQL APT repository
|
||||
sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
# Update repository and install postgresql tools
|
||||
sudo apt update
|
||||
sudo apt -y install postgresql-client-15
|
||||
|
||||
- name: Build and cache Docker image
|
||||
if: matrix.should_run == true
|
||||
uses: simplex-chat/docker-build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
file: Dockerfile.build
|
||||
tags: build/${{ matrix.os }}:latest
|
||||
build-args: |
|
||||
TAG=${{ matrix.os }}
|
||||
GHC=${{ matrix.ghc }}
|
||||
|
||||
- name: Cache dependencies
|
||||
if: matrix.should_run == true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cabal/store
|
||||
dist-newstyle
|
||||
key: ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplexmq.cabal') }}
|
||||
|
||||
- name: Start container
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
docker run -t -d \
|
||||
--device /dev/fuse \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt apparmor:unconfined \
|
||||
--name builder \
|
||||
-v ~/.cabal:/root/.cabal \
|
||||
-v /home/runner/work/_temp:/home/runner/work/_temp \
|
||||
-v ${{ github.workspace }}:/project \
|
||||
build/${{ matrix.os }}:latest
|
||||
|
||||
- name: Build smp-server (postgresql) and tests
|
||||
if: matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
run: |
|
||||
chmod -fR 777 ~/.cabal ./dist-newstyle || :; git config --global --add safe.directory '*'
|
||||
cabal clean
|
||||
cabal update
|
||||
cabal build --jobs=$(nproc) --enable-tests -fserver_postgres
|
||||
mkdir -p /out
|
||||
for i in smp-server simplexmq-test; do
|
||||
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
|
||||
chmod +x "$bin"
|
||||
mv "$bin" /out/
|
||||
done
|
||||
strip /out/smp-server
|
||||
|
||||
- name: Copy simplexmq-test from container
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
docker cp builder:/out/simplexmq-test .
|
||||
|
||||
- name: Copy smp-server (postgresql) from container and prepare it
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
id: prepare-postgres
|
||||
shell: bash
|
||||
run: |
|
||||
name="smp-server-postgres-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
|
||||
docker cp builder:/out/smp-server $name
|
||||
|
||||
path="${{ github.workspace }}/$name"
|
||||
echo "bin=$path" >> $GITHUB_OUTPUT
|
||||
|
||||
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
|
||||
printf 'hash=%s' "$hash" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build everything else (standard)
|
||||
if: matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
run: |
|
||||
cabal build --jobs=$(nproc)
|
||||
mkdir -p /out
|
||||
for i in ${{ env.apps }}; do
|
||||
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
|
||||
strip "$bin"
|
||||
chmod +x "$bin"
|
||||
mv "$bin" /out/
|
||||
done
|
||||
|
||||
- name: Copy binaries from container and prepare them
|
||||
id: prepare-regular
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
docker cp builder:/out .
|
||||
|
||||
printf 'bins<<EOF\n' > bins.output
|
||||
printf 'hashes<<EOF\n' > hashes.output
|
||||
for i in ${{ env.apps }}; do
|
||||
name="$i-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
|
||||
|
||||
mv ./out/$i ./$name
|
||||
|
||||
path="${{ github.workspace }}/$name"
|
||||
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
|
||||
|
||||
printf '%s\n' "$path" >> bins.output
|
||||
printf '%s\n\n' "$hash" >> hashes.output
|
||||
done
|
||||
printf 'EOF\n' >> bins.output
|
||||
printf 'EOF\n' >> hashes.output
|
||||
|
||||
cat bins.output >> "$GITHUB_OUTPUT"
|
||||
cat hashes.output >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload binaries
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
uses: simplex-chat/action-gh-release@v2
|
||||
with:
|
||||
append_body: true
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
${{ steps.prepare-regular.outputs.hashes }}
|
||||
${{ steps.prepare-postgres.outputs.hash }}
|
||||
files: |
|
||||
${{ steps.prepare-regular.outputs.bins }}
|
||||
${{ steps.prepare-postgres.outputs.bin }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Test
|
||||
if: matrix.should_run == true && matrix.arch == 'x86-64'
|
||||
timeout-minutes: 120
|
||||
shell: bash
|
||||
env:
|
||||
PGHOST: localhost
|
||||
run: |
|
||||
i=1
|
||||
attempts=1
|
||||
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if ./simplexmq-test; then
|
||||
break
|
||||
else
|
||||
echo "Attempt $i failed, retrying..."
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
if [ "$i" -gt "$attempts" ]; then
|
||||
echo "All "$attempts" attempts failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -14,22 +14,22 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- app: smp-server
|
||||
app_port: 5223
|
||||
app_port: "443 5223"
|
||||
- app: xftp-server
|
||||
app_port: 443
|
||||
app_port: 443
|
||||
steps:
|
||||
- name: Clone project
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: simplex-chat/docker-login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: simplex-chat/docker-metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.app }}
|
||||
flavor: |
|
||||
@@ -39,9 +39,17 @@ jobs:
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: simplex-chat/docker-build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
APP=${{ matrix.app }}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Reproduce latest release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # every day at 02:00 night
|
||||
|
||||
jobs:
|
||||
reproduce:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get latest release
|
||||
shell: bash
|
||||
run: |
|
||||
curl --proto '=https' \
|
||||
--tlsv1.2 \
|
||||
-sSf -L \
|
||||
'https://api.github.com/repos/simplex-chat/simplexmq/releases/latest' \
|
||||
2>/dev/null | \
|
||||
grep -i "tag_name" | \
|
||||
awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
|
||||
|
||||
- name: Execute reproduce script
|
||||
run: |
|
||||
${GITHUB_WORKSPACE}/scripts/simplexmq-reproduce-builds.sh "$TAG" || :
|
||||
|
||||
- name: Check if build has been reproduced
|
||||
env:
|
||||
url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
|
||||
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
|
||||
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
|
||||
run: |
|
||||
if [ -f "${GITHUB_WORKSPACE}/${TAG}-simplexmq/_sha256sums" ]; then
|
||||
exit 0
|
||||
else
|
||||
curl --proto '=https' --tlsv1.2 -sSf \
|
||||
-u "${user}:${pass}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
|
||||
"$url"
|
||||
exit 1
|
||||
fi
|
||||
@@ -11,3 +11,4 @@ cabal.project.local~
|
||||
.hpc/
|
||||
*.tix
|
||||
.coverage
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# XFTPClientAgent Pattern
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Changes: client.ts
|
||||
3. Changes: agent.ts
|
||||
4. Changes: test/browser.test.ts
|
||||
5. Verification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Add `XFTPClientAgent` — a per-server connection pool matching the Haskell pattern. The agent caches `XFTPClient` instances by server URL. All orchestration functions (`uploadFile`, `downloadFile`, `deleteFile`) take `agent` as first parameter and use `getXFTPServerClient(agent, server)` instead of calling `connectXFTP` directly. Connections stay open on success; the caller creates and closes the agent.
|
||||
|
||||
`connectXFTP` and `closeXFTP` stay exported (used by `XFTPWebTests.hs` Haskell tests). The `browserClients` hack, per-function `connections: Map`, and `getOrConnect` are deleted.
|
||||
|
||||
## Changes: client.ts
|
||||
|
||||
**Add** after types section: `XFTPClientAgent` interface, `newXFTPAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `closeXFTPAgent`.
|
||||
|
||||
**Delete**: `browserClients` Map and all `isNode` browser-cache checks in `connectXFTP` and `closeXFTP`.
|
||||
|
||||
**Revert `closeXFTP`** to unconditional `c.transport.close()` (browser transport.close() is already a no-op).
|
||||
|
||||
`connectXFTP` stays exported (backward compat) but becomes a raw low-level function — no caching.
|
||||
|
||||
## Changes: agent.ts
|
||||
|
||||
**Imports**: replace `connectXFTP`/`closeXFTP` with `getXFTPServerClient`/`closeXFTPAgent` etc.
|
||||
|
||||
**Re-export** from agent.ts: `newXFTPAgent`, `closeXFTPAgent`, `XFTPClientAgent`.
|
||||
|
||||
**`uploadFile`**: add `agent: XFTPClientAgent` as first param. Replace `connectXFTP` → `getXFTPServerClient`. Remove `finally { closeXFTP }`. Pass `agent` to `uploadRedirectDescription`.
|
||||
|
||||
**`uploadRedirectDescription`**: change from `(client, server, innerFd)` to `(agent, server, innerFd)`. Get client via `getXFTPServerClient`.
|
||||
|
||||
**`downloadFile`**: add `agent` param. Delete local `connections: Map`. Replace `getOrConnect` → `getXFTPServerClient`. Remove finally cleanup. Pass `agent` to `downloadWithRedirect`.
|
||||
|
||||
**`downloadWithRedirect`**: add `agent` param. Same replacements. Remove try/catch cleanup. Recursive call passes `agent`.
|
||||
|
||||
**`deleteFile`**: add `agent` param. Same pattern.
|
||||
|
||||
**Delete**: `getOrConnect` function entirely.
|
||||
|
||||
## Changes: test/browser.test.ts
|
||||
|
||||
Create agent before operations, pass to upload/download, close in finally.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npx vitest --run` — browser round-trip test passes
|
||||
2. No remaining `browserClients`, `getOrConnect`, or per-function `connections: Map` locals
|
||||
3. `connectXFTP` and `closeXFTP` still exported (XFTPWebTests.hs compat)
|
||||
4. All orchestration functions take `agent` as first param
|
||||
@@ -1,3 +1,368 @@
|
||||
# 6.4.4
|
||||
|
||||
Servers:
|
||||
- fix server pages when source code is not specified.
|
||||
- include commit SHA in printed version and in web page (#1608).
|
||||
|
||||
SMP server:
|
||||
- support short SimpleX addresses in server information page (#1600).
|
||||
- wrap all queries in transactions (#1603).
|
||||
|
||||
SMP agent:
|
||||
- chat relay address type for short links (#1602).
|
||||
- extend xrcp certificate validity 1 hour in the past, to allow out of sync clocks (#1601).
|
||||
|
||||
# 6.4.3
|
||||
|
||||
SMP agent:
|
||||
- fix some connection errors by updating contact request server hosts to match server in short link (#1597).
|
||||
|
||||
SMP server:
|
||||
- support short link URI as queue identifier in control port commands (#1596).
|
||||
|
||||
# 6.4.2
|
||||
|
||||
SMP server:
|
||||
- fix memory leak when connection interrupts straight after client connects.
|
||||
- do not include repeated queue blocking into stats/quota.
|
||||
|
||||
XFTP server:
|
||||
- prometheus metrics
|
||||
|
||||
# 6.4.1
|
||||
|
||||
SMP protocol:
|
||||
- create notification credentials via NEW command that creates the queue (#1586)
|
||||
|
||||
SMP server:
|
||||
- control port session improvements (#1591)
|
||||
- additional stat counter for ntf credentials created together with the queue (#1589)
|
||||
|
||||
# 6.4.0
|
||||
|
||||
SMP protocol (server/client):
|
||||
- support associated queue data and short connection links (see [RFC](./rfcs/2025-03-16-smp-queues.md)).
|
||||
- service certificates to optimize subscriptions.
|
||||
|
||||
SMP agent:
|
||||
- support retries for interactive connection handshakes.
|
||||
- use web port 443 by default for preset servers.
|
||||
- use static RNG function to avoid creating dynamic C stubs when generating sntrup keys (it was detected as Dynamic Code Loading in GrapheneOS).
|
||||
- different timeouts for interactive and background operations.
|
||||
|
||||
Ntf server:
|
||||
- PostgreSQL storage.
|
||||
- Prometheus metrics.
|
||||
- use service certificates.
|
||||
- fix repeat token registration.
|
||||
|
||||
# 6.3.2
|
||||
|
||||
Servers:
|
||||
- enable store log by default (#1501).
|
||||
|
||||
SMP server:
|
||||
- reduce memory usage (#1498)
|
||||
|
||||
SMP agent:
|
||||
- handle client/agent version downgrades after connection was established (#1508).
|
||||
|
||||
# 6.3.1
|
||||
|
||||
Servers:
|
||||
- handle ECONNABORTED error on client connections.
|
||||
- reproducible builds.
|
||||
- blocking records for content moderation.
|
||||
- update script (simplex-servers-update) downloads scripts from the specified or the latest stable tag.
|
||||
|
||||
SMP server:
|
||||
- support for PostgreSQL database for queue records for higher traffic servers.
|
||||
- fix old clients sending messages to new servers (#1443)
|
||||
- remove empty journals when opening message queues and expiring idle queues (#1456, #1458).
|
||||
- additional start options (#1465):
|
||||
- `maintenance` to run all start/stop operations without starting server.
|
||||
- `skip-warnings` to ignore the last corrupted line in store log (can happen on abnormal termination).
|
||||
|
||||
Ntf server:
|
||||
- record date of last token activity, to allow expiring inactive tokens.
|
||||
- additional token invalidation reasons in logs.
|
||||
|
||||
SMP agent:
|
||||
- store message sent to multiple connections only once, to reduce storage when sending to groups (#1453).
|
||||
- encrypt messages on delivery, to reduce database writes (#1446).
|
||||
- don't block method calls on congested sockets for better concurrency (#1454).
|
||||
- check notification token status on client connection.
|
||||
- option to skip SQLite vacuum on migrations.
|
||||
|
||||
# 6.3.0
|
||||
|
||||
SMP agent: fix joining connection after failure by using the same ratchet.
|
||||
|
||||
# 6.2.2
|
||||
|
||||
SMP server:
|
||||
- add optional Prometheus metrics (#1411).
|
||||
|
||||
Build:
|
||||
- remove three modules from client library.
|
||||
|
||||
# 6.2.0
|
||||
|
||||
Version 6.2.0.7
|
||||
|
||||
Build:
|
||||
- client_library flag to build only used modules in the clients, remove package yaml
|
||||
|
||||
SMP server:
|
||||
- journal storage for messages (BETA).
|
||||
- prevent race condition when deleting queue and to avoid "orphan" messages (#1395).
|
||||
|
||||
SMP agent:
|
||||
- support SMP and XFTP server roles (storage/proxy) and operators (#1343).
|
||||
- treat blocked STM and other critical errors that offer restart as temporary for message delivery (#1405).
|
||||
- fix inconsistent state after app restart while accepting contact request (#1412).
|
||||
|
||||
# 6.1.3
|
||||
|
||||
SMP server: fix restoring notification credentials.
|
||||
|
||||
# 6.1.2
|
||||
|
||||
Servers: more reliable restoring of state.
|
||||
|
||||
SMP server: reduced memory usage and faster start.
|
||||
|
||||
Notifications: compensate for iOS notifications being dropped by Apple while device is offline (#1378):
|
||||
- Ntf server: send multiple SMP notifications in one iOS notification.
|
||||
- Agent: get multiple messages for one iOS notification.
|
||||
|
||||
# 6.1.1
|
||||
|
||||
SMP:
|
||||
- stop server faster (#1371)
|
||||
- add STORE error (#1372)
|
||||
|
||||
# 6.1.0
|
||||
|
||||
Version 6.1.0.7
|
||||
|
||||
SMP server and client:
|
||||
- transport block encryption (#1317).
|
||||
|
||||
Agent:
|
||||
- batch and optimize iOS notifications processing (#1308, #1311, #1313, #1316, #1330, #1331, #1333, #1337, #1346).
|
||||
- allow receiving multiple messages from single iOS notification (#1355, #1362).
|
||||
- prepare connection to accept to avoid race condition with events (#1365).
|
||||
- transport isolation mode "Session" (default) to use new SOCKS credentials when client restarts or SOCKS proxy configuration changes (#1321).
|
||||
|
||||
Ntf server:
|
||||
- control port (#1354).
|
||||
- enable pings on ntf subscriptions, to resubscribe on reconnection (#1353).
|
||||
|
||||
SMP server:
|
||||
- support multiple server ports (#1319).
|
||||
- support serving HTTPS and SMP transport on the same port (#1326, #1327).
|
||||
- persist iOS notifications to avoid losing them when Ntf server is offline (#1336, #1339, #1350).
|
||||
- fix lost notification subscriptions (#1347).
|
||||
- reject SKEY with different key earlier, at verification step (#1366).
|
||||
- pass server information via CLI during server initialization (#1356).
|
||||
- show version on server page (#1341).
|
||||
- explicit graceful shutdown on SIGINT (#1360).
|
||||
|
||||
XRCP (remote access protocol):
|
||||
- use SHA3-256 in hybrid key agreement (#1302).
|
||||
- session encryption with forward secrecy (#1328).
|
||||
|
||||
# 6.0.5
|
||||
|
||||
SMP agent:
|
||||
- support generic SOCKS proxy (without isolate-by-auth).
|
||||
- reduce max message sizes
|
||||
|
||||
# 6.0.4
|
||||
|
||||
SMP server:
|
||||
- better performance/memory: fewer map updates on re-subscriptions (#1297), split and reduce STM transactions (#1294)
|
||||
- send DELD when subscribed queue is deleted (#1312)
|
||||
- add created/updated/used date to queues to manage expiration (#1306)
|
||||
|
||||
XFTP server: truncate file creation time to 1 hour (#1310)
|
||||
|
||||
Servers:
|
||||
- bind control port only to 127.0.0.1 for better security in case of firewall misconfiguration (#1280)
|
||||
- reduce memory used for period stats (#1298)
|
||||
|
||||
Agent: process last notification from list (#1307)
|
||||
- report receive file error with redirected file ID, when redirect is present (#1304)
|
||||
- special error when deleted user record is not in database (#1303)
|
||||
- fix race when sending a message to the deleted connection (#1296)
|
||||
- support for multiple messages in a single notification
|
||||
|
||||
Ntf server:
|
||||
- only use SOCKS proxy for servers without public address (#1314)
|
||||
|
||||
# 6.0.3
|
||||
|
||||
Agent:
|
||||
- fix possible stuck queue rotation (#1290).
|
||||
|
||||
SMP server:
|
||||
- batch END responses when subscribed client switches to reduce server and client traffic.
|
||||
- reduce STM transactions for better performance.
|
||||
- add stats for END events and for SUB/DEL event batches.
|
||||
- remove "expensive" stats to save memory.
|
||||
|
||||
# 6.0.2
|
||||
|
||||
SMP agent:
|
||||
- fix stuck connection commands when a server is not responding.
|
||||
- store query errors, reduce slow query threshold to 1ms.
|
||||
|
||||
Notification server:
|
||||
- reduce PING interval to 1 minute.
|
||||
- fix subscriptions disabled on race condition (only mark subscriptions with END status when received via the active connection).
|
||||
|
||||
# 6.0.1
|
||||
|
||||
SMP agent:
|
||||
- support changing user of the new connection.
|
||||
- do not start delivery workers when there are no messages to deliver.
|
||||
- enable notifications for all connections.
|
||||
- combine database transactions when subscribing.
|
||||
|
||||
SMP server:
|
||||
- safe compacting of store log.
|
||||
- fix possible race when creating client that might lead to memory leak.
|
||||
|
||||
Dependencies: upgrade tls to 1.9
|
||||
|
||||
# 6.0.0
|
||||
|
||||
Version 6.0.0.8
|
||||
|
||||
Agent:
|
||||
- enabled fast handshake support.
|
||||
- batch-send multiple messages in each connection.
|
||||
- resume subscriptions as soon as agent moves to foreground or as network connection resumes.
|
||||
- "known" servers to determine whether to use SMP proxy.
|
||||
- retry on SMP proxy NO_SESSION error.
|
||||
- fixes to notification subscriptions.
|
||||
- persistent server statistics.
|
||||
- better concurrency.
|
||||
|
||||
SMP server:
|
||||
- reduce threads usage.
|
||||
- additional statistics.
|
||||
- improve disabling inactive clients.
|
||||
- additional control port commands for monitoring.
|
||||
|
||||
Notification server:
|
||||
- support onion-only SMP servers.
|
||||
|
||||
# 5.8.2
|
||||
|
||||
Agent:
|
||||
- fast handshake support (disabled).
|
||||
- new statistics api.
|
||||
|
||||
SMP server:
|
||||
- fast handshake support (SKEY command).
|
||||
- minor changes to reduce memory usage.
|
||||
|
||||
# 5.8.1
|
||||
|
||||
Agent:
|
||||
- API to reconnect one server.
|
||||
- Better error handling of file errors and remote control connection errors.
|
||||
- Only start uploading file once all chunks were registered on the servers.
|
||||
|
||||
SMP server:
|
||||
- additional stats for sent message notifications.
|
||||
- fix server page layout.
|
||||
|
||||
# 5.8.0
|
||||
|
||||
Version 5.8.0.10
|
||||
|
||||
SMP server and client:
|
||||
- protocol extension to forward messages to the destination servers, to protect sending client IP address and transport session.
|
||||
|
||||
Agent:
|
||||
- process timed out subscription responses to reduce the number of resubscriptions.
|
||||
- avoid sending messages and commands when waiting for response timed out (except batched SUB and DEL commands).
|
||||
- fix issue with stuck message reception on slow connection (when response to ACK timed out, and the new message was not processed until resubscribed).
|
||||
- fix issue when temporary file sending or receiving error was treated as permanent.
|
||||
|
||||
SMP server:
|
||||
- include OK responses to all batched SUB requests to reduce subscription timeouts.
|
||||
|
||||
XFTP server:
|
||||
- report file upload timeout as TIMEOUT, to avoid delivery failure.
|
||||
|
||||
# 5.7.6
|
||||
|
||||
XFTP agent:
|
||||
- treat XFTP handshake timeouts and network errors as temporary, to retry file operations.
|
||||
|
||||
# 5.7.5
|
||||
|
||||
SMP agent:
|
||||
- fail if non-unique connection IDs are passed to sendMessages (to prevent client errors and deadlocks).
|
||||
|
||||
# 5.7.4
|
||||
|
||||
SMP agent:
|
||||
- remove re-subscription timeouts (as they are tracked per operation, and could cause failed subscriptions).
|
||||
- reconnect XFTP clients when network settings changes.
|
||||
- fix lock contention resulting in stuck subscriptions on network change.
|
||||
|
||||
# 5.7.3
|
||||
|
||||
SMP/NTF protocol:
|
||||
- add ALPN for handshake version negotiation, similar to XFTP (to preserve backwards compatibility with the old clients).
|
||||
- upgrade clients to versions v7/v2 of the protocols.
|
||||
|
||||
SMP server:
|
||||
- faster responses to subscription requests.
|
||||
|
||||
XFTP client:
|
||||
- fix network exception during file download treated as permanent file error.
|
||||
|
||||
SMP agent:
|
||||
- do not report subscription timeouts while client is offline.
|
||||
|
||||
# 5.7.2
|
||||
|
||||
SMP agent:
|
||||
- fix connections failing when connecting via link due to race condition on slow network.
|
||||
- remove concurrency limit when waiting for connection subscription.
|
||||
- remove TLS timeout.
|
||||
|
||||
# 5.7.1
|
||||
|
||||
SMP agent:
|
||||
- increase timeout for TLS connection via SOCKS
|
||||
|
||||
# 5.7.0
|
||||
|
||||
Version 5.7.0.4
|
||||
|
||||
_Please note_: the earliest SimpleX Chat clients supported by this version of the servers is 5.5.3 (released on February 11, 2024).
|
||||
|
||||
SMP server:
|
||||
- increase max SMP protocol version to 7 (support for deniable authenticators).
|
||||
|
||||
NTF server:
|
||||
- increase max NTF protocol version to 2 (support for deniable authenticators).
|
||||
|
||||
XFTP server:
|
||||
- version handshake using ALPN.
|
||||
|
||||
SMP agent:
|
||||
- increase timeouts for XFTP files.
|
||||
- don't send commands after timeout.
|
||||
- PQ encryption support.
|
||||
|
||||
# 5.6.2
|
||||
|
||||
Version 5.6.2.2.
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
ARG TAG=22.04
|
||||
# syntax=docker/dockerfile:1.7.0-labs
|
||||
ARG TAG=24.04
|
||||
|
||||
FROM ubuntu:${TAG} AS build
|
||||
|
||||
### Build stage
|
||||
|
||||
# Install curl and git and simplexmq dependencies
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-18 llvm-18-dev libnuma-dev libssl-dev
|
||||
|
||||
# Specify bootstrap Haskell versions
|
||||
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
|
||||
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
|
||||
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.12.1.0
|
||||
|
||||
# Do not install Stack
|
||||
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
|
||||
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
|
||||
|
||||
# Install ghcup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||
@@ -21,26 +26,42 @@ ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
|
||||
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
|
||||
|
||||
COPY . /project
|
||||
# Copy only the source code
|
||||
COPY apps /project/apps/
|
||||
COPY cbits /project/cbits/
|
||||
COPY src /project/src/
|
||||
|
||||
COPY cabal.project Setup.hs simplexmq.cabal LICENSE /project
|
||||
|
||||
WORKDIR /project
|
||||
|
||||
# Debug
|
||||
#ARG CACHEBUST=1
|
||||
|
||||
#ADD --chmod=755 https://github.com/MShekow/directory-checksum/releases/download/v1.4.6/directory-checksum_1.4.6_linux_amd64 /usr/local/bin/directory-checksum
|
||||
#RUN directory-checksum --max-depth 2 .
|
||||
|
||||
# Set build arguments and check if they exist
|
||||
ARG APP
|
||||
ARG APP_PORT
|
||||
RUN if [ -z "$APP" ] || [ -z "$APP_PORT" ]; then printf "Please spcify \$APP and \$APP_PORT build-arg.\n"; exit 1; fi
|
||||
RUN if [ -z "$APP" ]; then printf "Please spcify \$APP build-arg.\n"; exit 1; fi
|
||||
|
||||
# Compile app
|
||||
RUN cabal update
|
||||
RUN cabal build exe:$APP
|
||||
|
||||
# Copy scripts
|
||||
COPY scripts /project/scripts/
|
||||
|
||||
# Create new path containing all files needed
|
||||
RUN mkdir /final
|
||||
WORKDIR /final
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "$APP" -type f -executable) && \
|
||||
RUN bin="$(find /project/dist-newstyle -name "$APP" -type f -executable)" && \
|
||||
mv "$bin" ./ && \
|
||||
strip ./"$APP" &&\
|
||||
mv /project/scripts/docker/entrypoint-"$APP" ./entrypoint
|
||||
mv /project/scripts/docker/entrypoint-"$APP" ./entrypoint &&\
|
||||
mv /project/scripts/main/simplex-servers-stopscript ./simplex-servers-stopscript
|
||||
|
||||
### Final stage
|
||||
FROM ubuntu:${TAG}
|
||||
@@ -53,6 +74,8 @@ COPY --from=build /final /usr/local/bin/
|
||||
|
||||
# Open app listening port
|
||||
ARG APP_PORT
|
||||
RUN if [ -z "$APP_PORT" ]; then printf "Please spcify \$APP_PORT build-arg.\n"; exit 1; fi
|
||||
|
||||
EXPOSE $APP_PORT
|
||||
|
||||
# simplexmq requires using SIGINT to correctly preserve undelivered messages and restore them on restart
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1.7.0-labs
|
||||
ARG TAG=24.04
|
||||
FROM ubuntu:${TAG} AS build
|
||||
|
||||
### Build stage
|
||||
|
||||
ARG GHC=9.6.3
|
||||
ARG CABAL=3.14.1.1
|
||||
|
||||
# Install curl, git and and simplexmq dependencies
|
||||
RUN apt-get update && apt-get install -y curl libpq-dev git sqlite3 libsqlite3-dev build-essential libgmp3-dev zlib1g-dev llvm llvm-dev libnuma-dev libssl-dev
|
||||
|
||||
# Specify bootstrap Haskell versions
|
||||
ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
|
||||
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
|
||||
|
||||
# Do not install Stack
|
||||
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
|
||||
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
|
||||
|
||||
# Install ghcup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||
|
||||
# Adjust PATH
|
||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
|
||||
# Set both as default
|
||||
RUN ghcup set ghc "${GHC}" && \
|
||||
ghcup set cabal "${CABAL}"
|
||||
|
||||
WORKDIR /project
|
||||
@@ -1,6 +1,6 @@
|
||||
# SimpleXMQ
|
||||
|
||||
[](https://github.com/simplex-chat/simplexmq/actions?query=workflow%3Abuild)
|
||||
[](https://github.com/simplex-chat/simplexmq/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplexmq/releases)
|
||||
|
||||
📢 SimpleXMQ v1 is released - with many security, privacy and efficiency improvements, new functionality - see [release notes](https://github.com/simplex-chat/simplexmq/releases/tag/v1.0.0).
|
||||
@@ -116,7 +116,7 @@ On Linux, you can deploy smp and xftp server using Docker. This will download im
|
||||
2. Run your Docker container.
|
||||
|
||||
- `smp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain**. `-e "pass=password"` is optional variable to password-protect your `smp` server:
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -129,7 +129,7 @@ On Linux, you can deploy smp and xftp server using Docker. This will download im
|
||||
```
|
||||
|
||||
- `xftp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain** and **maximum_storage**.
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -149,8 +149,15 @@ On Linux, you can deploy smp and xftp server using Docker. This will download im
|
||||
You can install and setup servers automatically using our script:
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh \
|
||||
&& if echo 'b8cf2be103f21f9461d9a500bcd3db06ab7d01d68871b07f4bd245195cbead1d simplex-server-install.sh' | sha256sum -c; then chmod +x ./simplex-server-install.sh && ./simplex-server-install.sh; rm ./simplex-server-install.sh; else echo "SHA-256 checksum is incorrect!" && rm ./simplex-server-install.sh; fi
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\
|
||||
if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then
|
||||
chmod +x ./simplex-server-install.sh
|
||||
./simplex-server-install.sh
|
||||
rm ./simplex-server-install.sh
|
||||
else
|
||||
echo "SHA-256 checksum is incorrect!"
|
||||
rm ./simplex-server-install.sh
|
||||
fi
|
||||
```
|
||||
|
||||
### Build from source
|
||||
@@ -180,7 +187,7 @@ On Linux, you can build smp server using Docker.
|
||||
3. Run your Docker container.
|
||||
|
||||
- `smp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain**. `-e "pass=password"` is optional variable to password-protect your `smp` server:
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -193,7 +200,7 @@ On Linux, you can build smp server using Docker.
|
||||
```
|
||||
|
||||
- `xftp-server`
|
||||
|
||||
|
||||
You must change **your_ip_or_domain** and **maximum_storage**.
|
||||
```sh
|
||||
docker run -d \
|
||||
@@ -208,14 +215,17 @@ On Linux, you can build smp server using Docker.
|
||||
|
||||
#### Using your distribution
|
||||
|
||||
1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 8.10.7 and cabal:
|
||||
1. Install dependencies and build tools (`GHC`, `cabal` and dev libs):
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
|
||||
ghcup install ghc 8.10.7
|
||||
ghcup install cabal
|
||||
ghcup set ghc 8.10.7
|
||||
ghcup set cabal
|
||||
# On Ubuntu. Depending on your distribution, use your package manager to determine package names.
|
||||
sudo apt-get update && apt-get install -y build-essential curl libffi-dev libffi7 libgmp3-dev libgmp10 libncurses-dev libncurses5 libtinfo5 pkg-config zlib1g-dev libnuma-dev libssl-dev
|
||||
export BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
|
||||
export BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.3.0
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||
ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}"
|
||||
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
|
||||
source ~/.ghcup/env
|
||||
```
|
||||
|
||||
2. Build the project:
|
||||
@@ -224,10 +234,20 @@ On Linux, you can build smp server using Docker.
|
||||
git clone https://github.com/simplex-chat/simplexmq
|
||||
cd simplexmq
|
||||
git checkout stable
|
||||
# On Ubuntu. Depending on your distribution, use your package manager to determine package names.
|
||||
apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev
|
||||
cabal update
|
||||
cabal install
|
||||
cabal build exe:smp-server exe:xftp-server
|
||||
```
|
||||
|
||||
3. List compiled binaries:
|
||||
|
||||
`smp-server`
|
||||
```sh
|
||||
cabal list-bin exe:smp-server
|
||||
```
|
||||
|
||||
`xftp-server`
|
||||
```sh
|
||||
cabal list-bin exe:xftp-server
|
||||
```
|
||||
|
||||
- Initialize SMP server with `smp-server init [-l] -n <fqdn>` or `smp-server init [-l] --ip <ip>` - depending on how you initialize it, either FQDN or IP will be used for server's address.
|
||||
|
||||
@@ -15,7 +15,6 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
setLogLevel LogDebug -- change to LogError in production
|
||||
cfgPath <- getEnvPath "NTF_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "NTF_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ ntfServerCLI cfgPath logPath
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
|
||||
module Main where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Simplex.Messaging.Server.CLI (getEnvPath)
|
||||
import Simplex.Messaging.Server.Main
|
||||
import Simplex.Messaging.Server.Main (smpServerCLI_)
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticFiles)
|
||||
import SMPWeb (smpGenerateSite)
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
defaultCfgPath = "/etc/opt/simplex"
|
||||
@@ -17,7 +17,6 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
setLogLevel LogDebug
|
||||
cfgPath <- getEnvPath "SMP_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "SMP_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ smpServerCLI cfgPath logPath
|
||||
withGlobalLogging logCfg $ smpServerCLI_ smpGenerateSite serveStaticFiles attachStaticFiles cfgPath logPath
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module SMPWeb
|
||||
( smpGenerateSite,
|
||||
serverInformation,
|
||||
) where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.String (fromString)
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Server.Web.Embedded as E
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
|
||||
smpGenerateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO ()
|
||||
smpGenerateSite si onionHost path =
|
||||
Web.generateSite (serverInformation si onionHost) smpLinkPages path
|
||||
|
||||
smpLinkPages :: [String]
|
||||
smpLinkPages = ["contact", "invitation", "a", "c", "g", "r", "i"]
|
||||
|
||||
serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString
|
||||
serverInformation ServerInformation {config, information} onionHost = render E.indexHtml substs
|
||||
where
|
||||
substs = [("smpConfig", Just "y"), ("xftpConfig", Nothing)] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "smp-server.ini")]
|
||||
substConfig =
|
||||
[ ( "persistence",
|
||||
Just $ case persistence config of
|
||||
SPMMemoryOnly -> "In-memory only"
|
||||
SPMQueues -> "Queues"
|
||||
SPMMessages -> "Queues and messages"
|
||||
),
|
||||
("messageExpiration", Just $ maybe "Never" (fromString . timedTTLText) $ messageExpiration config),
|
||||
("statsEnabled", Just . yesNo $ statsEnabled config),
|
||||
("newQueuesAllowed", Just . yesNo $ newQueuesAllowed config),
|
||||
("basicAuthEnabled", Just . yesNo $ basicAuthEnabled config)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
@@ -1,8 +1,10 @@
|
||||
module Main where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Simplex.FileTransfer.Server.Main (xftpServerCLI_)
|
||||
import Simplex.Messaging.Server.CLI (getEnvPath)
|
||||
import Simplex.FileTransfer.Server.Main
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles)
|
||||
import XFTPWeb (xftpGenerateSite)
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
defaultCfgPath = "/etc/opt/simplex-xftp"
|
||||
@@ -18,4 +20,4 @@ main = do
|
||||
setLogLevel LogDebug -- change to LogError in production
|
||||
cfgPath <- getEnvPath "XFTP_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "XFTP_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ xftpServerCLI cfgPath logPath
|
||||
withGlobalLogging logCfg $ xftpServerCLI_ xftpGenerateSite serveStaticFiles cfgPath logPath
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module XFTPWeb
|
||||
( xftpGenerateSite,
|
||||
xftpServerInformation,
|
||||
) where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.String (fromString)
|
||||
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Expiration (ExpirationConfig (..))
|
||||
import Simplex.Messaging.Server.Information (ServerPublicInfo)
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Server.Web.Embedded as E
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
|
||||
xftpGenerateSite :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
|
||||
xftpGenerateSite cfg info onionHost path =
|
||||
Web.generateSite (xftpServerInformation cfg info onionHost) [] path
|
||||
|
||||
xftpServerInformation :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
|
||||
xftpServerInformation XFTPServerConfig {fileExpiration, logStatsInterval, allowNewFiles, newFileBasicAuth} information onionHost = render E.indexHtml substs
|
||||
where
|
||||
substs = [("smpConfig", Nothing), ("xftpConfig", Just "y")] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "file-server.ini")]
|
||||
substConfig =
|
||||
[ ("fileExpiration", Just $ maybe "Never" (fromString . timedTTLText . ttl) fileExpiration),
|
||||
("statsEnabled", Just . yesNo $ isJust logStatsInterval),
|
||||
("newUploadsAllowed", Just . yesNo $ allowNewFiles),
|
||||
("basicAuthEnabled", Just . yesNo $ isJust newFileBasicAuth)
|
||||
]
|
||||
yesNo True = "Yes"
|
||||
yesNo False = "No"
|
||||
@@ -4,6 +4,15 @@ packages: .
|
||||
-- packages: . ../http2
|
||||
-- packages: . ../network-transport
|
||||
|
||||
-- uncomment two sections below to run tests with coverage
|
||||
-- package *
|
||||
-- coverage: True
|
||||
-- library-coverage: True
|
||||
|
||||
-- package attoparsec
|
||||
-- coverage: False
|
||||
-- library-coverage: False
|
||||
|
||||
index-state: 2023-12-12T00:00:00Z
|
||||
|
||||
package cryptostore
|
||||
@@ -28,3 +37,17 @@ source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/sqlcipher-simple.git
|
||||
tag: a46bd361a19376c5211f1058908fc0ae6bf42446
|
||||
|
||||
-- waiting for published warp-tls-3.4.7
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/yesodweb/wai.git
|
||||
tag: ec5e017d896a78e787a5acea62b37a4e677dec2e
|
||||
subdir: warp-tls
|
||||
|
||||
-- backported fork due http-5.0
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/wai.git
|
||||
tag: 2f6e5aa5f05ba9140ac99e195ee647b4f7d926b0
|
||||
subdir: warp
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Coding and building
|
||||
|
||||
This file provides guidance on coding style and approaches and on building the code.
|
||||
|
||||
## Code Security
|
||||
|
||||
When designing code and planning implementations:
|
||||
- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious.
|
||||
- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances.
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
Haskell client and server code serves as system specification, not just implementation — we use type-driven design to reflect the business domain in types. Quality, conciseness, and clarity of Haskell code are critical.
|
||||
|
||||
## Code Style, Formatting and Approaches
|
||||
|
||||
The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`.
|
||||
|
||||
**Key formatting rules:**
|
||||
- 2-space indentation
|
||||
- Trailing function arrows, commas, and import/export style
|
||||
- Record brace without space: `{field = value}`
|
||||
- Single newline between declarations
|
||||
- Never use unicode symbols
|
||||
- Inline `let` style with right-aligned `in`
|
||||
|
||||
**Format code before committing:**
|
||||
|
||||
```bash
|
||||
# Format a single file
|
||||
fourmolu -i src/Simplex/Messaging/Protocol.hs
|
||||
```
|
||||
|
||||
Some files that use CPP language extension cannot be formatted as a whole, so individual code fragments need to be formatted.
|
||||
|
||||
**Follow existing code patterns:**
|
||||
- Match the style of surrounding code
|
||||
- Use qualified imports with short aliases (e.g., `import qualified Data.ByteString.Char8 as B`)
|
||||
- Use record syntax for types with multiple fields
|
||||
- Prefer explicit pattern matching over partial functions
|
||||
|
||||
**Comments policy:**
|
||||
- Avoid redundant comments that restate what the code already says
|
||||
- Only comment on non-obvious design decisions or tricky implementation details
|
||||
- Function names and type signatures should be self-documenting
|
||||
- Do not add comments like "wire format encoding" (Encoding class is always wire format) or "check if X" when the function name already says that
|
||||
- Assume a competent Haskell reader
|
||||
|
||||
**Diff and refactoring:**
|
||||
- Avoid unnecessary changes and code movements
|
||||
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring
|
||||
- Aim to minimize the code changes - do what is minimally required to solve users' problems
|
||||
|
||||
**Document and code structure:**
|
||||
- **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure.
|
||||
- When adding new sections to documents, continue the existing numbering scheme.
|
||||
- Minimize diff size - prefer small, targeted changes over reorganization.
|
||||
|
||||
**Code analysis and review:**
|
||||
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug.
|
||||
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication.
|
||||
- Do not save time on analysis. Read every function in the data flow even when the interface seems clear — wrong assumptions about internals are the main source of missed bugs.
|
||||
|
||||
### Haskell Extensions
|
||||
- `StrictData` enabled by default
|
||||
- Use STM for safe concurrency
|
||||
- Assume concurrency in PostgreSQL queries
|
||||
- Comprehensive warning flags with strict pattern matching
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Standard build
|
||||
cabal build
|
||||
|
||||
# Fast build
|
||||
cabal build --ghc-options -O0
|
||||
|
||||
# Build specific executables
|
||||
cabal build exe:smp-server exe:xftp-server exe:ntf-server exe:xftp
|
||||
|
||||
# Build with PostgreSQL server support
|
||||
cabal build -fserver_postgres
|
||||
|
||||
# Client-only library build (no server code)
|
||||
cabal build -fclient_library
|
||||
|
||||
# Find binary location
|
||||
cabal list-bin exe:smp-server
|
||||
```
|
||||
|
||||
### Cabal Flags
|
||||
|
||||
- `swift`: Enable Swift JSON format
|
||||
- `client_library`: Build without server code
|
||||
- `client_postgres`: Use PostgreSQL instead of SQLite for agent persistence
|
||||
- `server_postgres`: PostgreSQL support for server queue/notification store
|
||||
|
||||
## External Dependencies
|
||||
|
||||
Custom forks specified in `cabal.project`:
|
||||
- `aeson`, `hs-socks` (SimpleX forks)
|
||||
- `direct-sqlcipher`, `sqlcipher-simple` (encrypted SQLite)
|
||||
- `warp`, `warp-tls` (HTTP server)
|
||||
@@ -0,0 +1,105 @@
|
||||
# SimpleXMQ repository
|
||||
|
||||
This file provides guidance on the project structure to help working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SimpleXMQ is a Haskell message broker implementing unidirectional (simplex) queues for privacy-preserving messaging.
|
||||
|
||||
Key components:
|
||||
|
||||
- **SimpleX Messaging Protocol**: SMP protocol definition and encodings ([code](../src/Simplex/Messaging/Protocol.hs), [transport code](../src/Simplex/Messaging/Transport.hs), [spec](../protocol/simplex-messaging.md)).
|
||||
- **SMP Server**: Message broker with TLS, in-memory queues, optional persistence ([main code](../src/Simplex/Messaging/Server.hs), [all code files](../src/Simplex/Messaging/Server/), [executable](../apps/smp-server/)). For proxying SMP commands the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
|
||||
- **SMP Client**: Functional API with STM-based message delivery ([code](../src/Simplex/Messaging/Client.hs)).
|
||||
- **SMP Agent**: High-level duplex connections via multiple simplex queues with E2E encryption ([code](../src/Simplex/Messaging/Agent.hs)). Implements Agent-to-agent protocol ([code](../src/Simplex/Messaging/Agent/Protocol.hs), [spec](../protocol/agent-protocol.md)) via intermediary agent client ([code](../src/Simplex/Messaging/Agent/Client.hs)).
|
||||
- **XFTP**: SimpleX File Transfer Protocol, server and CLI client ([code](../src/Simplex/FileTransfer/), [spec](../protocol/xftp.md)).
|
||||
- **XRCP**: SimpleX Remote Control Protocol ([code](`../src/Simplex/RemoteControl/`), [spec](../protocol/xrcp.md)).
|
||||
- **Notifications**: Push notifications server requires PostgreSQL ([code](../src/Simplex/Messaging/Notifications), [executable](../apps/ntf-server/)). Client protocol is used for clients to communicate with the server ([code](../src/Simplex/Messaging/Notifications/Protocol.hs), [spec](../protocol/push-notifications.md)). For subscribing to SMP notifications the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
|
||||
|
||||
## Architecture
|
||||
|
||||
For general overview see `../protocol/overview-tjr.md`.
|
||||
|
||||
SMP Protocol Layers:
|
||||
|
||||
```
|
||||
TLS Transport → SMP Protocol → Agent Protocol → Application protocol
|
||||
```
|
||||
|
||||
XFTP Protocol Layers:
|
||||
|
||||
```
|
||||
TLS Transport (HTTP2 encoding) → XFTP Protocol → Out-of-band file descriptions
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Persistence**: All queue state managed via Software Transactional Memory or via PostgreSQL
|
||||
- `Simplex.Messaging.Server.MsgStore.STM` - in-memory messages
|
||||
- `Simplex.Messaging.Server.QueueStore.STM` - in-memory queue state
|
||||
- `Simplex.Messaging.Server.MsgStore.Postgres` - message storage
|
||||
- `Simplex.Messaging.Server.QueueStore.Postgres` - queue storage
|
||||
|
||||
2. **Append-Only Store Log**: Optional persistence via journal for in-memory storage
|
||||
- `Simplex.Messaging.Server.StoreLog` - queue creation log
|
||||
- Compacted on restart
|
||||
|
||||
3. **Agent Storage**:
|
||||
- SQLite (default) or PostgreSQL
|
||||
- Migrations in `src/Simplex/Messaging/Agent/Store/{SQLite,Postgres}/Migrations/`
|
||||
|
||||
4. **Protocol Versioning**: All layers support version negotiation
|
||||
- `Simplex.Messaging.Version` - version range utilities
|
||||
|
||||
5. **Double Ratchet E2E**: Per-connection encryption
|
||||
- `Simplex.Messaging.Crypto.Ratchet`
|
||||
- SNTRUP761 post-quantum KEM (`src/Simplex/Messaging/Crypto/SNTRUP761/`)
|
||||
|
||||
## Source Layout
|
||||
|
||||
```
|
||||
src/Simplex/
|
||||
├── Messaging/
|
||||
│ ├── Agent.hs # Main agent (~210KB)
|
||||
│ ├── Server.hs # SMP server (~130KB)
|
||||
│ ├── Client.hs # Client API (~65KB)
|
||||
│ ├── Protocol.hs # Protocol types (~77KB)
|
||||
│ ├── Crypto.hs # E2E encryption (~52KB)
|
||||
│ ├── Transport.hs # Transport encoding over TLS
|
||||
│ ├── Agent/Store/ # SQLite/Postgres persistence
|
||||
│ ├── Server/ # Server internals (QueueStore, MsgStore, Control)
|
||||
│ └── Notifications/ # Push notification system
|
||||
├── FileTransfer/ # XFTP implementation for file transfers
|
||||
└── RemoteControl/ # XRCP implementation for device discovery & control
|
||||
```
|
||||
|
||||
## Protocol Documentation
|
||||
|
||||
- `protocol/overview-tjr.md`: SMP protocols stack overview
|
||||
- `protocol/simplex-messaging.md`: SMP protocol spec (v19)
|
||||
- `protocol/agent-protocol.md`: Agent protocol spec (v7)
|
||||
- `protocol/xftp.md`: File transfer protocol
|
||||
- `protocol/xrcp.md`: Remote control protocol
|
||||
- `rfcs/`: Design RFCs for features
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cabal test --test-show-details=streaming
|
||||
|
||||
# Run specific test group (uses HSpec)
|
||||
cabal test --test-option=--match="/Core tests/Encryption tests/"
|
||||
|
||||
# Run single test
|
||||
cabal test --test-option=--match="/SMP client agent/functional API/"
|
||||
```
|
||||
|
||||
Tests require PostgreSQL running on `localhost:5432` when using `-fserver_postgres` or `-fclient_postgres`.
|
||||
|
||||
Test files are in `tests/` with structure:
|
||||
- `Test.hs`: Main runner
|
||||
- `AgentTests/`: Agent protocol and connection tests
|
||||
- `CoreTests/`: Crypto, encoding, storage tests
|
||||
- `ServerTests.hs`: SMP server tests
|
||||
- `XFTPServerTests.hs`: File transfer tests
|
||||
@@ -0,0 +1,23 @@
|
||||
# Contributing to SimpleX repositories
|
||||
|
||||
## Focus on user problems
|
||||
|
||||
We do not make code changes to improve code - any change must address a specific user problem or request.
|
||||
|
||||
## Discuss the plans as early as possible
|
||||
|
||||
Please discuss the problem you want to solve and your detailed implementation plan with the project team prior to contributing, to avoid wasted time and additional changes. Acceptance of your contribution depends on your willingness and ability to iterate the proposed contribution to achieve the required quality level, coding style, test coverage, and alignment with user requirements as they are understood by the project team.
|
||||
|
||||
## Follow project structure, coding style and approaches
|
||||
|
||||
./PROJECT.md has information about the structure of this `simplexmq` repository.
|
||||
|
||||
./CODE.md has details about general requirements common for `simplexmq` and `simplex-chat` repositories.
|
||||
|
||||
This files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files:
|
||||
|
||||
```markdown
|
||||
@README.md
|
||||
@contributing/PROJECT.md
|
||||
@contributing/CODE.md
|
||||
```
|
||||
@@ -2,10 +2,6 @@
|
||||
set -eu
|
||||
|
||||
# Links to scripts/configs
|
||||
bin="https://github.com/simplex-chat/simplexmq/releases/latest/download"
|
||||
bin_smp="$bin/smp-server-ubuntu-20_04-x86-64"
|
||||
bin_xftp="$bin/xftp-server-ubuntu-20_04-x86-64"
|
||||
|
||||
scripts="https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/scripts/main"
|
||||
scripts_systemd_smp="$scripts/smp-server.service"
|
||||
scripts_systemd_xftp="$scripts/xftp-server.service"
|
||||
@@ -26,6 +22,8 @@ path_conf_var="/var/opt"
|
||||
path_conf_smp="$path_conf_etc/simplex $path_conf_var/simplex"
|
||||
path_conf_xftp="$path_conf_etc/simplex-xftp $path_conf_var/simplex-xftp /srv/xftp"
|
||||
|
||||
path_conf_info="$path_conf_etc/simplex-info"
|
||||
|
||||
path_systemd="/etc/systemd/system"
|
||||
path_systemd_smp="$path_systemd/smp-server.service"
|
||||
path_systemd_xftp="$path_systemd/xftp-server.service"
|
||||
@@ -56,7 +54,7 @@ ${GRN}1.${NC} Install latest binaries from GitHub releases:
|
||||
${GRN}2.${NC} Create server directories:
|
||||
- smp: ${YLW}${path_conf_smp}${NC}
|
||||
- xftp: ${YLW}${path_conf_xftp}${NC}
|
||||
${GRN}3.${NC} Setup user for each server:
|
||||
${GRN}3.${NC} Setup user for server:
|
||||
- xmp: ${YLW}${user_smp}${NC}
|
||||
- xftp: ${YLW}${user_xftp}${NC}
|
||||
${GRN}4.${NC} Create systemd services:
|
||||
@@ -65,7 +63,12 @@ ${GRN}4.${NC} Create systemd services:
|
||||
${GRN}5.${NC} Install stopscript (systemd), update and uninstallation script:
|
||||
- all: ${YLW}${path_bin_update}${NC}, ${YLW}${path_bin_uninstall}${NC}, ${YLW}${path_bin_stopscript}${NC}
|
||||
|
||||
Press ${GRN}ENTER${NC} to continue or ${RED}Ctrl+C${NC} to cancel installation"
|
||||
Press:
|
||||
- ${GRN}1${NC} to install smp server
|
||||
- ${GRN}2${NC} to install xftp server
|
||||
- ${RED}Ctrl+C${NC} to cancel installation
|
||||
|
||||
Selection: "
|
||||
|
||||
end="Installtion is complete!
|
||||
|
||||
@@ -76,27 +79,79 @@ Please checkout our server guides:
|
||||
To uninstall with full clean-up, simply run: ${YLW}sudo /usr/local/bin/simplex-servers-uninstall${NC}
|
||||
"
|
||||
|
||||
set_version() {
|
||||
ver="${VER:-latest}"
|
||||
|
||||
case "$ver" in
|
||||
latest)
|
||||
bin="https://github.com/simplex-chat/simplexmq/releases/latest/download"
|
||||
remote_version="$(curl --proto '=https' --tlsv1.2 -sSf -L https://api.github.com/repos/simplex-chat/simplexmq/releases/latest | grep -i "tag_name" | awk -F \" '{print $4}')"
|
||||
;;
|
||||
*)
|
||||
bin="https://github.com/simplex-chat/simplexmq/releases/download/${ver}"
|
||||
remote_version="${ver}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
os_test() {
|
||||
. /etc/os-release
|
||||
|
||||
case "$VERSION_ID" in
|
||||
20.04|22.04) : ;;
|
||||
24.04) VERSION_ID='22.04' ;;
|
||||
*) printf "${RED}Unsupported Ubuntu version!${NC}\nPlease file Github issue with request to support Ubuntu %s: https://github.com/simplex-chat/simplexmq/issues/new\n" "$VERSION_ID" && exit 1 ;;
|
||||
esac
|
||||
|
||||
version="$(printf '%s' "$VERSION_ID" | tr '.' '_')"
|
||||
arch="$(uname -p)"
|
||||
|
||||
case "$arch" in
|
||||
x86_64) arch="$(printf '%s' "$arch" | tr '_' '-')" ;;
|
||||
*) printf "${RED}Unsupported architecture!${NC}\nPlease file Github issue with request to support %s architecture: https://github.com/simplex-chat/simplexmq/issues/new" "$arch" && exit 1 ;;
|
||||
esac
|
||||
|
||||
bin_smp="$bin/smp-server-ubuntu-${version}-${arch}"
|
||||
bin_xftp="$bin/xftp-server-ubuntu-${version}-${arch}"
|
||||
}
|
||||
|
||||
setup_bins() {
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L "$bin_smp" -o "$path_bin_smp" && chmod +x "$path_bin_smp"
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L "$bin_xftp" -o "$path_bin_xftp" && chmod +x "$path_bin_xftp"
|
||||
eval "bin=\$bin_${1}"
|
||||
eval "path=\$path_bin_${1}"
|
||||
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L "$bin" -o "$path" && chmod +x "$path"
|
||||
|
||||
unset bin path
|
||||
}
|
||||
|
||||
setup_users() {
|
||||
useradd -M "$user_smp" 2> /dev/null || true
|
||||
useradd -M "$user_xftp" 2> /dev/null || true
|
||||
eval "user=\$user_${1}"
|
||||
|
||||
useradd -M "$user" 2> /dev/null || true
|
||||
|
||||
unset user
|
||||
}
|
||||
|
||||
setup_dirs() {
|
||||
# Unquoted varibles, so field splitting can occur
|
||||
mkdir -p $path_conf_smp
|
||||
chown "$user_smp":"$user_smp" $path_conf_smp
|
||||
mkdir -p $path_conf_xftp
|
||||
chown "$user_xftp":"$user_xftp" $path_conf_xftp
|
||||
eval "path_conf=\$path_conf_${1}"
|
||||
eval "user=\$user_${1}"
|
||||
|
||||
mkdir -p $path_conf
|
||||
mkdir -p $path_conf_info
|
||||
printf "local_version_%s='%s'\n" "$1" "$remote_version" >> "$path_conf_info/release"
|
||||
chown -R "$user":"$user" $path_conf
|
||||
|
||||
unset path_conf user
|
||||
}
|
||||
|
||||
setup_systemd() {
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L "$scripts_systemd_smp" -o "$path_systemd_smp"
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L "$scripts_systemd_xftp" -o "$path_systemd_xftp"
|
||||
eval "scripts_systemd=\$scripts_systemd_${1}"
|
||||
eval "path_systemd=\$path_systemd_${1}"
|
||||
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L "$scripts_systemd" -o "$path_systemd"
|
||||
|
||||
unset scripts_systemd path_systemd
|
||||
}
|
||||
|
||||
setup_scripts() {
|
||||
@@ -110,32 +165,61 @@ checks() {
|
||||
printf "This script is intended to be run with root privileges. Please re-run script using sudo."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set_version
|
||||
os_test
|
||||
|
||||
mkdir -p $path_conf_info
|
||||
}
|
||||
|
||||
main() {
|
||||
checks
|
||||
|
||||
printf "%b\n%b\n" "${BLU}$logo${NC}" "$welcome"
|
||||
printf "%b\n%b" "${BLU}$logo${NC}" "$welcome"
|
||||
read ans
|
||||
|
||||
case "$ans" in
|
||||
1) setup='smp' ;;
|
||||
2) setup='xftp' ;;
|
||||
*) printf 'Installation aborted.\n' && exit 0 ;;
|
||||
esac
|
||||
|
||||
printf "Installing binaries..."
|
||||
setup_bins
|
||||
|
||||
for i in $setup; do
|
||||
setup_bins "$i"
|
||||
done
|
||||
|
||||
printf "${GRN} Done!${NC}\n"
|
||||
|
||||
printf "Creating users..."
|
||||
setup_users
|
||||
|
||||
for i in $setup; do
|
||||
setup_users "$i"
|
||||
done
|
||||
|
||||
printf "${GRN} Done!${NC}\n"
|
||||
|
||||
printf "Creating directories..."
|
||||
setup_dirs
|
||||
|
||||
for i in $setup; do
|
||||
setup_dirs "$i"
|
||||
done
|
||||
|
||||
printf "${GRN} Done!${NC}\n"
|
||||
|
||||
printf "Creating systemd services..."
|
||||
setup_systemd
|
||||
|
||||
for i in $setup; do
|
||||
setup_systemd "$i"
|
||||
done
|
||||
|
||||
printf "${GRN} Done!${NC}\n"
|
||||
|
||||
printf "Installing stopscript, update and uninstallation script..."
|
||||
|
||||
setup_scripts
|
||||
|
||||
printf "${GRN} Done!${NC}\n"
|
||||
|
||||
printf "%b" "$end"
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
common:
|
||||
corrId - random BS, used as CbNonce
|
||||
entityId - p2r tlsUniq
|
||||
|
||||
# setup
|
||||
s->p: "proxy", uri, auth?
|
||||
# unless connected
|
||||
p->r: "p_handshake"
|
||||
p<-r: "r_key", tls-signed dh pub
|
||||
s<-r: "r_key", tls-signed dh pub # reply entityId contains tlsUniq
|
||||
|
||||
# working
|
||||
s ; generate random dh priv, make shared secret
|
||||
s->p: s2r("forward", random dh pub, SEND command blob)
|
||||
p->r: p2r("forward", random dh pub, s2r("forward", ...)))
|
||||
r->c@ "msg", ...
|
||||
p<-r: p2r("r_res", s2r("ok" / "error", error))
|
||||
s<-p@ s2r("ok" / "error", error)
|
||||
|
||||
# expired
|
||||
p<-r@ p2r("error", "key expired")
|
||||
s<-p@ "error", "key expired"
|
||||
s ; reconnect
|
||||
@@ -1,190 +0,0 @@
|
||||
name: simplexmq
|
||||
version: 5.6.2.2
|
||||
synopsis: SimpleXMQ message broker
|
||||
description: |
|
||||
This package includes <./docs/Simplex-Messaging-Server.html server>,
|
||||
<./docs/Simplex-Messaging-Client.html client> and
|
||||
<./docs/Simplex-Messaging-Agent.html agent> for SMP protocols:
|
||||
.
|
||||
* <https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md SMP protocol>
|
||||
* <https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md SMP agent protocol>
|
||||
.
|
||||
See <https://github.com/simplex-chat/simplex-chat terminal chat prototype> built with SimpleXMQ broker.
|
||||
|
||||
homepage: https://github.com/simplex-chat/simplexmq#readme
|
||||
license: AGPL-3
|
||||
author: simplex.chat
|
||||
maintainer: chat@simplex.chat
|
||||
copyright: 2020-2022 simplex.chat
|
||||
category: Chat, Network, Web, System, Cryptography
|
||||
extra-source-files:
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- cbits/sha512.h
|
||||
- cbits/sntrup761.h
|
||||
|
||||
dependencies:
|
||||
- aeson == 2.2.*
|
||||
- ansi-terminal >= 0.10 && < 0.12
|
||||
- asn1-encoding == 0.9.*
|
||||
- asn1-types == 0.3.*
|
||||
- async == 2.2.*
|
||||
- attoparsec == 0.14.*
|
||||
- base >= 4.14 && < 5
|
||||
- base64-bytestring >= 1.0 && < 1.3
|
||||
- case-insensitive == 1.2.*
|
||||
- composition == 1.0.*
|
||||
- constraints >= 0.12 && < 0.14
|
||||
- containers == 0.6.*
|
||||
- crypton == 0.34.*
|
||||
- crypton-x509 == 1.7.*
|
||||
- crypton-x509-store == 1.6.*
|
||||
- crypton-x509-validation == 1.6.*
|
||||
- cryptostore == 0.3.*
|
||||
- data-default == 0.7.*
|
||||
- direct-sqlcipher == 2.3.*
|
||||
- directory == 1.3.*
|
||||
- filepath == 1.4.*
|
||||
- hourglass == 0.2.*
|
||||
- http-types == 0.12.*
|
||||
- http2 >= 4.2.2 && < 4.3
|
||||
- ini == 0.4.1
|
||||
- iproute == 1.7.*
|
||||
- iso8601-time == 0.1.*
|
||||
- memory == 0.18.*
|
||||
- mtl >= 2.3.1 && < 3.0
|
||||
- network >= 3.1.2.7 && < 3.2
|
||||
- network-info >= 0.2 && < 0.3
|
||||
- network-transport == 0.5.6
|
||||
- network-udp >= 0.0 && < 0.1
|
||||
- optparse-applicative >= 0.15 && < 0.17
|
||||
- process == 1.6.*
|
||||
- random >= 1.1 && < 1.3
|
||||
- simple-logger == 0.1.*
|
||||
- socks == 0.6.*
|
||||
- sqlcipher-simple == 0.4.*
|
||||
- stm == 2.5.*
|
||||
- temporary == 1.3.*
|
||||
- time == 1.12.*
|
||||
- time-manager == 0.0.*
|
||||
- tls >= 1.7.0 && < 1.8
|
||||
- transformers == 0.6.*
|
||||
- unliftio == 0.2.*
|
||||
- unliftio-core == 0.2.*
|
||||
- websockets == 0.12.*
|
||||
- yaml == 0.11.*
|
||||
- zstd == 0.1.3.*
|
||||
|
||||
flags:
|
||||
swift:
|
||||
description: Enable swift JSON format
|
||||
manual: True
|
||||
default: False
|
||||
use_crypton:
|
||||
description: Use crypton etc. in cryptostore
|
||||
manual: True
|
||||
default: True
|
||||
|
||||
when:
|
||||
- condition: flag(swift)
|
||||
cpp-options:
|
||||
- -DswiftJSON
|
||||
- condition: impl(ghc >= 9.6.2)
|
||||
dependencies:
|
||||
- bytestring == 0.11.*
|
||||
- template-haskell == 2.20.*
|
||||
- text >= 2.0.1 && < 2.2
|
||||
- condition: impl(ghc < 9.6.2)
|
||||
dependencies:
|
||||
- bytestring == 0.10.*
|
||||
- template-haskell == 2.16.*
|
||||
- text >= 1.2.3.0 && < 1.3
|
||||
|
||||
library:
|
||||
source-dirs: src
|
||||
c-sources:
|
||||
- cbits/sha512.c
|
||||
- cbits/sntrup761.c
|
||||
include-dirs: cbits
|
||||
extra-libraries: crypto
|
||||
|
||||
executables:
|
||||
smp-server:
|
||||
source-dirs: apps/smp-server
|
||||
main: Main.hs
|
||||
dependencies:
|
||||
- simplexmq
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
|
||||
ntf-server:
|
||||
source-dirs: apps/ntf-server
|
||||
main: Main.hs
|
||||
dependencies:
|
||||
- simplexmq
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
|
||||
xftp-server:
|
||||
source-dirs: apps/xftp-server
|
||||
main: Main.hs
|
||||
dependencies:
|
||||
- simplexmq
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
|
||||
smp-agent:
|
||||
source-dirs: apps/smp-agent
|
||||
main: Main.hs
|
||||
dependencies:
|
||||
- simplexmq
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
|
||||
xftp:
|
||||
source-dirs: apps/xftp
|
||||
main: Main.hs
|
||||
dependencies:
|
||||
- simplexmq
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
|
||||
tests:
|
||||
simplexmq-test:
|
||||
source-dirs: tests
|
||||
main: Test.hs
|
||||
dependencies:
|
||||
- simplexmq
|
||||
- deepseq == 1.4.*
|
||||
- generic-random == 1.5.*
|
||||
- hspec == 2.11.*
|
||||
- hspec-core == 2.11.*
|
||||
- HUnit == 1.6.*
|
||||
- QuickCheck == 2.14.*
|
||||
- silently == 1.2.*
|
||||
- main-tester == 0.2.*
|
||||
- timeit == 2.0.*
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
- -with-rtsopts=-A64M
|
||||
- -with-rtsopts=-N1
|
||||
|
||||
ghc-options:
|
||||
# - -haddock
|
||||
- -Wall
|
||||
- -Wcompat
|
||||
- -Werror=incomplete-patterns
|
||||
- -Wredundant-constraints
|
||||
- -Wincomplete-record-updates
|
||||
- -Wincomplete-uni-patterns
|
||||
- -Wunused-type-patterns
|
||||
- -O2
|
||||
|
||||
default-extensions:
|
||||
- StrictData
|
||||
@@ -1,3 +1,5 @@
|
||||
Version 5, 2024-06-22
|
||||
|
||||
# SMP agent protocol - duplex communication over SMP protocol
|
||||
|
||||
## Table of contents
|
||||
@@ -5,69 +7,61 @@
|
||||
- [Abstract](#abstract)
|
||||
- [SMP agent](#smp-agent)
|
||||
- [SMP servers management](#smp-servers-management)
|
||||
- [SMP agent protocol components](#smp-agent-protocol-components)
|
||||
- [SMP agent protocol scope](#smp-agent-protocol-scope)
|
||||
- [Duplex connection procedure](#duplex-connection-procedure)
|
||||
- [Contact addresses](#contact-addresses)
|
||||
- [Communication between SMP agents](#communication-between-smp-agents)
|
||||
- [Message syntax](#messages-between-smp-agents)
|
||||
- [HELLO message](#hello-message)
|
||||
- [REPLY message](#reply-message)
|
||||
- [MSG message](#msg-message)
|
||||
- [INV message](#inv-message)
|
||||
- [ACK message](#ack-message)
|
||||
- [NEW message](#new-message)
|
||||
- [DEL message](#del-message)
|
||||
- [SMP agent commands](#smp-agent-commands)
|
||||
- [Client commands and server responses](#client-commands-and-server-responses)
|
||||
- [NEW command and INV response](#new-command-and-inv-response)
|
||||
- [JOIN command](#join-command)
|
||||
- [CONF notification and LET command](#conf-notification-and-let-command)
|
||||
- [REQ notification and ACPT command](#req-notification-and-acpt-command)
|
||||
- [INFO and CON notifications](#info-and-con-notifications)
|
||||
- [SUB command](#sub-command)
|
||||
- [SEND command and MID, SENT and MERR responses](#send-command-and-mid-sent-and-merr-responses)
|
||||
- [MSG notification](#msg-notification)
|
||||
- [END notification](#end-notification)
|
||||
- [OFF command](#off-command)
|
||||
- [DEL command](#del-command)
|
||||
- [Connection request](#connection-request)
|
||||
- [A_MSG message](#a_msg-message)
|
||||
- [A_RCVD message](#a_rcvd-message)
|
||||
- [EREADY message](#eready-message)
|
||||
- [A_QCONT message](#a_qcont-message)
|
||||
- [Rotating messaging queue](#rotating-messaging-queue)
|
||||
- [End-to-end encryption](#end-to-end-encryption)
|
||||
- [Connection link: 1-time invitation and contact address](#connection-link-1-time-invitation-and-contact-address)
|
||||
- [Appendix A: SMP agent API](#smp-agent-api)
|
||||
- [API functions](#api-functions)
|
||||
- [API events](#api-events)
|
||||
|
||||
## Abstract
|
||||
|
||||
The purpose of SMP agent protocol is to define the syntax and the semantics of communications between the client and the agent that connects to [SMP](./simplex-messaging.md) servers.
|
||||
|
||||
It provides:
|
||||
- protocol to create and manage bi-directional (duplex) connections between the users of SMP agents consisting of two (or more) separate unidirectional (simplex) SMP queues, abstracting away multiple steps required to establish bi-directional connections and any information about the servers location from the users of the agent protocol.
|
||||
- API to create and manage bi-directional (duplex) connections between the users of SMP agents consisting of two (or more) separate unidirectional (simplex) SMP queues, abstracting away multiple steps required to establish bi-directional connections and any information about the servers location from the users of the agent protocol.
|
||||
- management of E2E encryption between SMP agents, generating ephemeral asymmetric keys for each connection.
|
||||
- SMP command authentication on SMP servers, generating ephemeral keys for each SMP queue.
|
||||
- TCP/TLS transport handshake with SMP servers.
|
||||
- validation of message integrity.
|
||||
|
||||
SMP agent protocol provides no encryption or security on the client side - it is assumed that the agent is executed in the trusted and secure environment, in one of three ways:
|
||||
- via TCP network using secure connection.
|
||||
- via local port (when the agent runs on the same device as a separate process).
|
||||
- via agent library, when the agent logic is included directly into the client application - [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) uses this approach.
|
||||
SMP agent API provides no security between the agent and the client - it is assumed that the agent is executed in the trusted and secure environment, via the agent library, when the agent logic is included directly into the client application - [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) uses this approach.
|
||||
|
||||
## SMP agent
|
||||
|
||||
SMP agents communicate with each other via SMP servers using [simplex messaging protocol (SMP)](./simplex-messaging.md) according to the commands received from its users. This protocol is a middle layer in SimpleX protocols (above SMP protocol but below any application level protocol) - it is intended to be used by client-side applications that need secure asynchronous bi-directional communication channels ("connections").
|
||||
SMP agents communicate with each other via SMP servers using [simplex messaging protocol (SMP)](./simplex-messaging.md) according to the API calls used by the client applications. This protocol is a middle layer in SimpleX protocols (above SMP protocol but below any application level protocol) - it is intended to be used by client-side applications that need secure asynchronous bi-directional communication channels ("connections").
|
||||
|
||||
The agent must have a persistent storage to manage the states of known connections and of the client-side information of SMP queues that each connection consists of, and also the buffer of the most recent sent and received messages. The number of the messages that should be stored is implementation specific, depending on the error management approach that the agent implements; at the very least the agent must store the hashes and IDs of the last received and sent messages.
|
||||
|
||||
## SMP servers management
|
||||
|
||||
SMP agent protocol commands do not contain the addresses of the SMP servers that the agent will use to create and use the connections (excluding the server address in queue URIs used in JOIN command). The list of the servers is a part of the agent configuration and can be dynamically changed by the agent implementation:
|
||||
SMP agent API does not use the addresses of the SMP servers that the agent will use to create and use the connections (excluding the server address in queue URIs used in JOIN command). The list of the servers is a part of the agent configuration and can be dynamically changed by the agent implementation:
|
||||
- by the client applications via any API that is outside of scope of this protocol.
|
||||
- by the agents themselves based on availability and latency of the configured servers.
|
||||
|
||||
## SMP agent protocol components
|
||||
## SMP agent protocol scope
|
||||
|
||||
SMP agent protocol has 3 main parts:
|
||||
SMP agent protocol has 2 main parts:
|
||||
|
||||
- the syntax and semantics of the messages that SMP agents exchange with each other in order to:
|
||||
- the messages that SMP agents exchange with each other in order to:
|
||||
- negotiate establishing unidirectional (simplex) encrypted queues on SMP servers.
|
||||
- exchange client messages and delivery notifications, providing sequential message IDs and message integrity (by including the hash of the previous message).
|
||||
- the syntax and semantics of the commands that are sent by the agent clients to the agents. This protocol allows to create and manage multiple connections, each consisting of two or more SMP queues.
|
||||
- the syntax and semantics of the message that the clients of SMP agents should send out-of-band (as pre-shared "invitation" including queue URIs) to protect [E2E encryption][1] from active attacks ([MITM attacks][2]).
|
||||
- re-negotiate messaging queues to use and connection e2e encryption.
|
||||
- the messages that the clients of SMP agents should send out-of-band (as pre-shared "invitation" including queue URIs) to protect [E2E encryption][1] from active attacks ([MITM attacks][2]).
|
||||
|
||||
[Appendix A](#appendix-a-smp-agent-api) of this document describes:
|
||||
- the functional API used by the client application with the agent. This API allows to create and manage multiple connections, each consisting of two or more SMP queues.
|
||||
- events that the agent passes to the clients.
|
||||
|
||||
## Duplex connection procedure
|
||||
|
||||
@@ -75,54 +69,126 @@ SMP agent protocol has 3 main parts:
|
||||
|
||||
The procedure of establishing a duplex connection is explained on the example of Alice and Bob creating a bi-directional connection consisting of two unidirectional (simplex) queues, using SMP agents (A and B) to facilitate it, and two different SMP servers (which could be the same server). It is shown on the diagram above and has these steps:
|
||||
|
||||
1. Alice requests the new connection from the SMP agent A using SMP NEW command.
|
||||
2. Agent A creates an SMP connection on the server (using [SMP protocol](./simplex-messaging.md)) and responds to Alice with the invitation that contains queue information and the encryption key Bob's agent B should use. The invitation format is described in [Connection request](#connection-request).
|
||||
3. Alice sends the [connection request](#connection-request) to Bob via any secure channel (out-of-band message).
|
||||
4. Bob sends `JOIN` command with the connection request as a parameter to agent B to accept the connection.
|
||||
5. Establishing Alice's SMP queue (with SMP protocol commands):
|
||||
- Agent B sends an "SMP confirmation" with SMP SEND command to the SMP queue specified in the connection request - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, etc.). This message is encrypted using key passed in the connection request (or with the derived key, in which case public key for key derivation should be sent in clear text).
|
||||
- Agent A receives the SMP confirmation containing Bob's key and info as SMP MSG.
|
||||
- Agent A notifies Alice sending REQ notification with Bob's info.
|
||||
- Alice accepts connection request with ACPT command.
|
||||
- Agent A secures the queue with SMP KEY command.
|
||||
- Agent B tries sending authenticated SMP SEND command with agent `HELLO` message until it succeeds. Once it succeeds, Bob's agent "knows" the queue is secured.
|
||||
6. Agent B creates a new SMP queue on the server.
|
||||
7. Establish Bob's SMP queue:
|
||||
- Agent B sends `REPLY` message (SMP SEND command) with the connection request to this 2nd queue to Alice's agent (via the 1st queue) - this connection request SHOULD use "simplex" URI scheme.
|
||||
- Agent A, having received `REPLY` message, sends unauthenticated message (SMP SEND) to SMP queue with Alice agent's ephemeral key that will be used to authenticate Alice's commands to the queue, as described in SMP protocol, and Alice's info.
|
||||
- Bob's agent receives the key and Alice's information and secures the queue (SMP KEY).
|
||||
- Bob's agent sends the notification `INFO` with Alice's information to Bob.
|
||||
- Alice's agent keeps sending `HELLO` message until it succeeds.
|
||||
8. Agents A and B notify Alice and Bob that connection is established.
|
||||
- Once sending `HELLO` succeeds, Alice's agent sends to Alice `CON` notification that confirms that now both parties can communicate.
|
||||
- Once Bob's agent receives `HELLO` from Alice's agent, it sends to Bob `CON` notification as well.
|
||||
1. Alice requests the new connection from the SMP agent A using agent `createConnection` api function.
|
||||
2. Agent A creates an SMP queue on the server (using [SMP protocol](./simplex-messaging.md) `NEW` command) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address).
|
||||
3. Alice sends the [connection link](#connection-link-1-time-invitation-and-contact-address) to Bob via any secure channel (out-of-band message) - as a link or as a QR code.
|
||||
4. Bob uses agent `joinConnection` api function with the connection link as a parameter to agent B to accept the connection.
|
||||
5. Agent B creates Bob's SMP reply queue with SMP server `NEW` command.
|
||||
6. Agent B confirms the connection: sends an "SMP confirmation" with SMP server `SEND` command to the SMP queue specified in the connection link - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, and the connection link to this 2nd queue to Agent A - this connection link SHOULD use "simplex" URI scheme). This message is encrypted using key passed in the connection link (or with the derived shared secret, in which case public key for key derivation should be sent in clear text).
|
||||
6. Alice confirms and continues the connection:
|
||||
- Agent A receives the SMP confirmation containing Bob's key, reply queue and info as SMP server `MSG`.
|
||||
- Agent A notifies Alice sending `CONF` notification with Bob's info.
|
||||
- Alice allows connection to continue with agent `allowConnection` api function.
|
||||
- Agent A secures the queue with SMP server `KEY` command.
|
||||
- Agent A sends SMP confirmation with ephemeral sender key, ephemeral public encryption key and profile (but without reply queue).
|
||||
7. Agent B confirms the connection:
|
||||
- receives the confirmation.
|
||||
- sends the notification `INFO` with Alice's information to Bob.
|
||||
- secures SMP queue that it sent to Alice in the first confirmation with SMP `KEY` command .
|
||||
- sends `HELLO` message via SMP `SEND` command. This confirms that the reply queue is secured and also validates that Agent A secured the first SMP queue
|
||||
8. Agent A notifies Alice.
|
||||
- receives `HELLO` message from Agent B.
|
||||
- sends `HELLO` message to Agent B via SMP `SEND` command.
|
||||
- sends `CON` notification to Alice, confirming that the connection is established.
|
||||
9. Agent B notifies Bob.
|
||||
- Once Agent B receives `HELLO` from Agent A, it sends to Bob `CON` notification as well.
|
||||
|
||||
At this point the duplex connection between Alice and Bob is established, they can use `SEND` command to send messages. The diagram also shows how the connection status changes for both parties, where the first part is the status of the SMP queue to receive messages, and the second part - the status of the queue to send messages.
|
||||
|
||||
The most communication happens between the agents and servers, from the point of view of Alice and Bob there are 4 steps (not including notifications):
|
||||
|
||||
1. Alice requests a new connection with `NEW` command and receives the invitation.
|
||||
2. Alice passes connection request out-of-band to Bob.
|
||||
3. Bob accepts the connection with `JOIN` command with the connection request to his agent.
|
||||
4. Alice accepts the connection with `ACPT` command.
|
||||
1. Alice requests a new connection with `createConnection` agent API function and receives the connection link.
|
||||
2. Alice passes connection link out-of-band to Bob.
|
||||
3. Bob accepts the connection with `joinConnection` agent API function with the connection link to his agent.
|
||||
4. Alice accepts the connection with `ACPT` agent API function.
|
||||
5. Both parties receive `CON` notification once duplex connection is established.
|
||||
|
||||
Clients SHOULD support establishing duplex connection asynchronously (when parties are intermittently offline) by persisting intermediate states and resuming SMP queue subscriptions.
|
||||
|
||||
## Fast duplex connection procedure
|
||||
|
||||
Previously described duplex connection procedure requires sending 4 messages creating a bad UX for the users - it requires waiting until each party in online before the messages can be sent.
|
||||
|
||||
It allows users validating connecting party profile before proceeding with the connection, but it turned out to be unnecessary UX step and is not used in the client applications.
|
||||
|
||||
It also protects against an attacker who compromised TLS and uses the sender queue ID sent to the recipient to secure the queue before the sender can. This attack is very hard, and this accepting its risk is better than worse UX. Future protocol versions could mitigate this attack by encrypting entity IDs.
|
||||
|
||||
Faster duplex connection process is possible with the `SKEY` command added in v9 of SMP protocol.
|
||||
|
||||

|
||||
|
||||
1. Alice requests the new connection from the SMP agent A using agent `createConnection` api function
|
||||
2. Agent A creates an SMP queue on the server (using [SMP protocol](./simplex-messaging.md) `NEW` command with the flag allowing the sender to secure the queue) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address).
|
||||
3. Alice sends the [connection link](connection-link-1-time-invitation-and-contact-address) to Bob via any secure channel (out-of-band message) - as a link or as a QR code. This link contains the flag that the queue can be secured by the sender.
|
||||
4. Bob uses agent `joinConnection` api function with the connection link as a parameter to agent B to accept the connection.
|
||||
5. Agent B secures Alice's queue with SMP command `SKEY` - this command can be proxied.
|
||||
6. Agent B creates Bob's SMP reply queue with SMP server `NEW` command (with the flag allowing the sender to secure the queue).
|
||||
7. Agent B confirms the connection: sends an "SMP confirmation" with SMP server `SEND` command to the SMP queue specified in the connection link - SMP confirmation is an unauthenticated message with an ephemeral key that will be used to authenticate Bob's commands to the queue, as described in SMP protocol, and Bob's info (profile, public key for E2E encryption, and the connection link to this 2nd queue to Agent A - this connection link SHOULD use "simplex" URI scheme). This message is encrypted using key passed in the connection link (or with the derived shared secret, in which case public key for key derivation should be sent in clear text).
|
||||
8. Alice confirms the connection:
|
||||
- Agent A receives the SMP confirmation containing Bob's key, reply queue and info as SMP server `MSG`.
|
||||
- Agent A notifies Alice sending `CONF` notification with Bob's info (that indicates that Agent B already secured the queue).
|
||||
- Alice allows connection to continue with agent `allowConnection` api function.
|
||||
- Agent A secures Bob's queue with SMP command `SKEY`.
|
||||
- Agent A sends SMP confirmation with ephemeral public encryption key and profile (but without reply queue, and without sender key).
|
||||
9. Agent A notifies Alice with `CON` notification.
|
||||
10. Agent B notifies Bob about connection success:
|
||||
- receives confirmation message from Alice.
|
||||
- sends the notification `INFO` with Alice's information to Bob.
|
||||
- sends `CON` notification to Bob.
|
||||
|
||||
## Contact addresses
|
||||
|
||||
SMP agents support creating a special type of connection - a contact address - that allows to connect to multiple network users who can send connection requests by sending 1-time connection links to the message queue.
|
||||
|
||||
This connection address uses a messaging queue on SMP server to receive invitations to connect - see `agentInvitation` message below. Once connection request is accepted, a new connection is created and the address itself is no longer used to send the messages - deleting this address does not disrupt the connections that were created via it.
|
||||
|
||||
## Communication between SMP agents
|
||||
|
||||
To establish duplex connections and to send messages on behalf of their clients, SMP agents communicate via SMP servers.
|
||||
|
||||
Agents use SMP message client body (the part of the SMP message after header - see [SMP protocol](./simplex-messaging.md)) to transmit agent client messages and exchange messages between each other.
|
||||
|
||||
Each SMP message client body, once decrypted, contains 3 parts (one of them may include binary message body), as defined by `decryptedSmpMessageBody` syntax:
|
||||
These messages are encrypted with per-queue shared secret using NaCL crypto_box and can be of 4 types, as defined by `decryptedSMPClientMessage`:
|
||||
- `agentConfirmation` - used when confirming SMP queues, contains connection information encrypted with double ratchet. This envelope can only contain `agentConnInfo` or `agentConnInfoReply` encrypted with double ratchet.
|
||||
- `agentMsgEnvelope` - contains different agent messages encrypted with double ratchet, as defined in `agentMessage`.
|
||||
- `agentInvitation` - sent to SMP queue that is used as contact address, does not use double ratchet.
|
||||
- `agentRatchetKey` - used to re-negotiate double ratchet encryption - can contain additional information in `agentRatchetKey`.
|
||||
|
||||
```abnf
|
||||
decryptedSMPClientMessage = agentConfirmation / agentMsgEnvelope / agentInvitation / agentRatchetKey
|
||||
agentConfirmation = agentVersion %s"C" ("0" / "1" sndE2EEncryptionParams) encConnInfo
|
||||
agentVersion = 2*2 OCTET
|
||||
sndE2EEncryptionParams = TODO
|
||||
encConnInfo = doubleRatchetEncryptedMessage
|
||||
|
||||
agentMsgEnvelope = agentVersion %s"M" encAgentMessage
|
||||
encAgentMessage = doubleRatchetEncryptedMessage
|
||||
|
||||
agentInvitation = agentVersion %s"I" connReqLength connReq connInfo
|
||||
connReqLength = 2*2 OCTET ; Word16
|
||||
|
||||
agentRatchetKey = agentVersion %s"R" rcvE2EEncryptionParams agentRatchetInfo
|
||||
rcvE2EEncryptionParams = TODO
|
||||
|
||||
doubleRatchetEncryptedMessage = TODO
|
||||
```
|
||||
|
||||
This syntax of decrypted SMP client message body is defined by `decryptedAgentMessage` below.
|
||||
|
||||
Decrypted SMP message client body can be one of 4 types:
|
||||
- `agentConnInfo` - used by the initiating party when confirming reply queue - sent in `agentConfirmation` envelope.
|
||||
- `agentConnInfoReply` - used by accepting party, includes reply queue(s) in the initial confirmation - sent in `agentConfirmation` envelope.
|
||||
- `agentRatchetInfo` - used to pass additional information when renegotiating double ratchet encryption - sent in `agentRatchetKey` envelope.
|
||||
- `agentMessage` - all other agent messages.
|
||||
|
||||
`agentMessage` contains these parts:
|
||||
- `agentMsgHeader` - agent message header that contains sequential agent message ID for a particular SMP queue, agent timestamp (ISO8601) and the hash of the previous message.
|
||||
- `agentMessage` - a command/message to the other SMP agent:
|
||||
- to establish the connection with two SMP queues (`helloMsg`, `replyQueueMsg`)
|
||||
- to send and to acknowledge user messages (`clientMsg`, `acknowledgeMsg`)
|
||||
- to manage SMP queue rotation (`newQueueMessage`, `deleteQueueMsg`)
|
||||
- to manage encryption key rotation (TODO)
|
||||
- `aMessage` - a command/message to the other SMP agent:
|
||||
- to confirm the connection (`HELLO`).
|
||||
- to send and to confirm reception of user messages (`A_MSG`, `A_RCVD`).
|
||||
- to confirm that the new double ratchet encryption is agreed (`EREADY`).
|
||||
- to notify another party that it can continue sending messages after queue capacity was exceeded (`A_QCONT`).
|
||||
- to manage SMP queue rotation (`QADD`, `QKEY`, `QUSE`, `QTEST`).
|
||||
- `msgPadding` - an optional message padding to make all SMP messages have constant size, to prevent servers from observing the actual message size. The only case the message padding can be absent is when the message has exactly the maximum size, in all other cases the message MUST be padded to a fixed size.
|
||||
|
||||
### Messages between SMP agents
|
||||
@@ -130,269 +196,160 @@ Each SMP message client body, once decrypted, contains 3 parts (one of them may
|
||||
Message syntax below uses [ABNF][3] with [case-sensitive strings extension][4].
|
||||
|
||||
```abnf
|
||||
decryptedSmpMessageBody = agentMsgHeader CRLF agentMessage CRLF msgPadding
|
||||
agentMsgHeader = agentMsgId SP previousMsgHash ; here `agentMsgId` is sequential ID set by the sending agent
|
||||
agentMsgId = 1*DIGIT
|
||||
previousMsgHash = encoded
|
||||
encoded = <base64 encoded>
|
||||
decryptedAgentMessage = agentConnInfo / agentConnInfoReply / agentRatchetInfo / agentMessage
|
||||
agentConnInfo = %s"I" connInfo
|
||||
connInfo = *OCTET
|
||||
agentConnInfoReply = %s"D" smpQueues connInfo
|
||||
agentRatchetInfo = %s"R" ratchetInfo
|
||||
|
||||
agentMessage = helloMsg / replyQueueMsg /
|
||||
clientMsg / invitationMsg /
|
||||
newQueueMessage / deleteQueueMsg
|
||||
agentMessage = %s"M" agentMsgHeader aMessage msgPadding
|
||||
agentMsgHeader = agentMsgId prevMsgHash
|
||||
agentMsgId = 8*8 OCTET ; Int64
|
||||
prevMsgHash = shortString
|
||||
|
||||
msgPadding = *OCTET ; optional random bytes to get messages to the same size (as defined in SMP message size)
|
||||
aMessage = HELLO / A_MSG / A_RCVD / EREADY / A_QCONT /
|
||||
QADD / QKEY / QUSE / QTEST
|
||||
|
||||
helloMsg = %s"H"
|
||||
HELLO = %s"H"
|
||||
|
||||
replyQueueMsg = %s"R" connectionRequest ; `connectionRequest` is defined below
|
||||
; this message can only be sent by the second connection party
|
||||
A_MSG = %s"M" userMsgBody
|
||||
userMsgBody = *OCTET
|
||||
|
||||
clientMsg = %s"M" clientMsgBody
|
||||
clientMsgBody = *OCTET
|
||||
A_RCVD = %s"V" msgReceipt
|
||||
msgReceipt = agentMsgId msgHash rcptLength rcptInfo
|
||||
|
||||
; TODO remove and move to "public" header
|
||||
invitationMsg = %s"INV" SP connReqInvitation SP connInfo
|
||||
; `connReqInvitation` and `connInfo` are defined below
|
||||
EREADY = %s"E" agentMsgId
|
||||
|
||||
newQueueMsg = %s"N" queueURI
|
||||
; this message can be sent by any party to add SMP queue to the connection.
|
||||
; NOT SUPPORTED in the current implementation
|
||||
A_QCONT = %s"QC" sndQueueAddr
|
||||
|
||||
deleteQueueMsg = %s"D" queueURI
|
||||
; notification that the queue with passed URI will be deleted
|
||||
; no need to notify the other party about suspending queue separately, as suspended and deleted queues are indistinguishable to the sender
|
||||
; NOT SUPPORTED in the current implementation
|
||||
QADD = %s"QA" sndQueues
|
||||
sndQueues = length 1*(newQueueUri replacedSndQueue)
|
||||
newQueueUri = clientVRange smpServer senderId dhPublicKey [sndSecure]
|
||||
dhPublicKey = length x509encoded
|
||||
sndSecure = "T"
|
||||
replacedSndQueue = "0" / "1" sndQueueAddr
|
||||
|
||||
QKEY = %s"QK" sndQueueKeys
|
||||
sndQueueKeys = length 1*(newQueueInfo senderKey)
|
||||
newQueueInfo = version smpServer senderId dhPublicKey [sndSecure]
|
||||
senderKey = length x509encoded
|
||||
|
||||
QUSE = %s"QU" sndQueuesReady
|
||||
sndQueuesReady = length 1*(sndQueueAddr primary)
|
||||
primary = %s"T" / %s"F"
|
||||
|
||||
QTEST = %s"QT" sndQueueAddrs
|
||||
sndQueueAddrs = length 1*sndQueueAddr
|
||||
|
||||
sndQueueAddr = smpServer senderId
|
||||
smpServer = hosts port keyHash
|
||||
hosts = length 1*host
|
||||
host = shortString
|
||||
port = shortString
|
||||
keyHash = shortString
|
||||
senderId = shortString
|
||||
|
||||
clientVRange = version version
|
||||
version = 2*2 OCTET
|
||||
|
||||
msgPadding = *OCTET
|
||||
rcptLength = 2*2 OCTET
|
||||
shortString = length *OCTET
|
||||
length = 1*1 OCTET
|
||||
```
|
||||
|
||||
#### HELLO message
|
||||
|
||||
This is the first message that both agents send after the respective SMP queue is secured by the receiving agent (see diagram). It MAY contain the public key that the recipient would use to verify messages signed by the sender.
|
||||
This is the first message that both agents send after the respective SMP queue is secured by the receiving agent (see diagram).
|
||||
|
||||
Sending agent might need to retry sending HELLO message, as it would not have any other confirmation that the queue is secured other than the success of sending this message with the signed SMP SEND command.
|
||||
This message is not used with [fast duplex connection](#fast-duplex-connection-procedure).
|
||||
|
||||
#### REPLY message
|
||||
#### A_MSG message
|
||||
|
||||
This is the message that is sent by the agent that received an out-of-band connection request to pass the connection request for the reply SMP queues to the agent that originated the connection (see diagram).
|
||||
This is the agent envelope used to send client messages once the connection is established. This is different from the MSG sent by SMP server to the agent and MSG event from SMP agent to the client that are sent in different contexts.
|
||||
|
||||
#### MSG message
|
||||
#### A_RCVD message
|
||||
|
||||
This is the agent envelope used to send client messages once the connection is established. Do not confuse it with the MSG response from SMP server to the agent and MSG response from SMP agent to the client that are sent in different contexts.
|
||||
This message is sent to confirm the client message reception. It includes received message number and message hash.
|
||||
|
||||
#### INV message
|
||||
#### EREADY message
|
||||
|
||||
This message is sent to the SMP queue(s) in `connReqContact`, to establish a new connection via existing unsecured queue, that acts as a permanent connection link of a user.
|
||||
This message is sent after re-negotiating a new double ratchet encryption with `agentRatchetKey`.
|
||||
|
||||
#### ACK message
|
||||
#### A_QCONT message
|
||||
|
||||
This message is sent to confirm the client message reception. It includes received message number, message hash and the reception status.
|
||||
This message is sent to notify the sender client that it can continue sending the messages after queue capacity was exhausted.
|
||||
|
||||
#### NEW message
|
||||
### Rotating messaging queue
|
||||
|
||||
This message is sent to add an additional SMP queue to the connection. Unlike REPLY message it can be sent at any time.
|
||||
SMP agents SHOULD support 4 messages to rotate message reception to another messaging server:
|
||||
`QADD`: add the new queue address(es) to the connection - sent by the client that initiates rotation.
|
||||
`QKEY`: pass sender's key via existing connection (SMP confirmation message will not be used, to avoid the same "race" of the initial key exchange that would create the risk of intercepting the queue for the attacker) - sent by the client accepting the rotation
|
||||
`QUSE`: instruct the sender to use the new queue with sender's queue ID as parameter. From this point some messages can be sent to both the new queue and the old queue.
|
||||
`QTEST`: send test message to the new connection. Any other message can be sent if available to continue rotation, the absence of this message is not an error. Once this message is successfully sent the sender will stop using the old queue. Once this message (or any other message in the new queue) is received, the recipient will stop using the old queue and delete it.
|
||||
|
||||
#### DEL message
|
||||
**Queue rotation procedure**
|
||||
|
||||
This message is sent to notify that the queue with passed URI will be deleted - having received this message, the receiving agent should no longer send messages to this queue. In case it was the last remaining send queue in the duplex connection, the agent MAY also delete the reply queue(s) in the connection.
|
||||

|
||||
|
||||
## SMP agent commands
|
||||
`SKEY` command added in v9 of SMP protocol allows for faster queue rotation procedure.
|
||||
|
||||
This part describes the transmissions between users and client-side SMP agents: commands that the users send to create and operate duplex connections and SMP agent responses and messages they deliver.
|
||||
**Fast queue rotation procedure**
|
||||
|
||||
Commands syntax below is provided using [ABNF][3] with [case-sensitive strings extension][4].
|
||||

|
||||
|
||||
Each transmission between the user and SMP agent must have this format/syntax:
|
||||
## End-to-end encryption
|
||||
|
||||
```abnf
|
||||
agentTransmission = [corrId] CRLF [connId] CRLF agentCommand
|
||||
Messages between SMP agents have two layers of e2e encryption:
|
||||
- simple encryption agreed in SMP protocol with a fixed key agreed when the messaging queue is agreed by parties.
|
||||
- post-quantum resistant augmented double ratchet algorithm (PQDR) specified in [this document](./pqdr.md).
|
||||
|
||||
corrId = 1*(%x21-7F) ; any characters other than control/whitespace
|
||||
The protocol supports adding and removing post-quantum KEM primitive to the key agreement in double ratchet:
|
||||
- to support migration of pre-existing connections to PQDR.
|
||||
- to be able to disable PQ key agreement.
|
||||
- to be able to use invitation links and contact addresses without large PQ keys.
|
||||
|
||||
connId = encoded
|
||||
Possible scenarios below show the possible states of PQ key agreement, assuming that both clients support it.
|
||||
|
||||
agentCommand = (userCmd / agentMsg) CRLF
|
||||
userCmd = newCmd / joinCmd / letCmd / acceptCmd / subscribeCmd / sendCmd / acknowledgeCmd / suspendCmd / deleteCmd
|
||||
agentMsg = invitation / confMsg / connReqMsg / connInfo / connected / unsubscribed / connDown / connUp / messageId / sent / messageError / message / received / ok / error
|
||||
Possible options for each stage are:
|
||||
- no KEM encapsulation key was sent (No PQ key),
|
||||
- only KEM encapsulation key was sent, but not ciphertext yet (PQ key sent),
|
||||
- both KEM encapsulation key from one KEM agreement and ciphertext from the previous agreement were sent (PQ key + PQ ct sent).
|
||||
|
||||
newCmd = %s"NEW" SP connectionMode [SP %s"NO_ACK"] ; response is `invitation` or `error`
|
||||
; NO_ACK parameter currently not supported
|
||||
`+` in the table means that this scenario is possible, and `-` - that it is not possible.
|
||||
|
||||
connectionMode = %s"INV" / %s"CON"
|
||||
| Connection stage | No PQ key | PQ key sent | PQ key + PQ ct sent |
|
||||
|:------------------------------------------------------:|:----------------:|:----------------:|:-------------------:|
|
||||
| invitation | + | + | - |
|
||||
| confirmation, in reply to: <br>no-pq inv <br>pq inv | <br>+<br>+ | <br>+<br>- | <br>-<br>+ |
|
||||
| 1st msg, in reply to: <br>no-pq conf <br>pq/pq+ct conf | <br>+<br>+ | <br>+<br>- | <br>-<br>+ |
|
||||
| Nth msg, in reply to: <br>no-pq msg <br>pq/pq+ct msg | <br>+<br>+ | <br>+<br>- | <br>-<br>+ |
|
||||
|
||||
invitation = %s"INV" SP connectionRequest ; `connectionRequest` is defined below
|
||||
These scenarios can be reduced to:
|
||||
1. initial invitation optionally has PQ key, but must not have ciphertext.
|
||||
2. all subsequent messages should be allowed without PQ key/ciphertext, but:
|
||||
- if the previous message had PQ key or PQ key with ciphertext, they must either have no PQ key, or have PQ key with ciphertext (PQ key without ciphertext is an error).
|
||||
- if the previous message had no PQ key, they must either have no PQ key, or have PQ key without ciphertext (PQ key with ciphertext is an error).
|
||||
|
||||
confMsg = %s"CONF" SP confirmationId SP msgBody
|
||||
; msgBody here is any binary information identifying connection request
|
||||
The rules for calculating the shared secret for received/sent messages are (assuming received message is valid according to the above rules):
|
||||
|
||||
letCmd = %s"LET" SP confirmationId SP msgBody
|
||||
; msgBody here is any binary information identifying connecting party
|
||||
| sent msg > <br>V received msg | no-pq | pq | pq+ct |
|
||||
|:------------------------------:|:-----------:|:-------:|:---------------:|
|
||||
| no-pq | DH / DH | DH / DH | err |
|
||||
| pq (sent msg was NOT pq) | DH / DH | err | DH / DH+KEM |
|
||||
| pq+ct (sent msg was NOT no-pq) | DH+KEM / DH | err | DH+KEM / DH+KEM |
|
||||
|
||||
confirmationId = 1*DIGIT
|
||||
To summarize, the upgrade to DH+KEM secret happens in a sent message that has PQ key with ciphertext sent in reply to message with PQ key only (without ciphertext), and the downgrade to DH secret happens in the message that has no PQ key.
|
||||
|
||||
connReqMsg = %s"REQ" SP invitationId SP msgBody
|
||||
; msgBody here is any binary information identifying connection request
|
||||
## Connection link: 1-time invitation and contact address
|
||||
|
||||
acceptCmd = %s"ACPT" SP invitationId SP msgBody
|
||||
; msgBody here is any binary information identifying connecting party
|
||||
Connection links are generated by SMP agent in response to `createConnection` api call, used by another party user with `joinConnection` api, and then another connection link is sent by the agent in `agentConnInfoReply` and used by the first party agent to connect to the reply queue (the second part of the process is invisible to the users).
|
||||
|
||||
invitationId = 1*DIGIT
|
||||
|
||||
connInfo = %s"INFO" SP msgBody
|
||||
; msgBody here is any binary information identifying connecting party
|
||||
|
||||
connected = %s"CON"
|
||||
|
||||
subscribeCmd = %s"SUB" ; response is `ok` or `error`
|
||||
|
||||
unsubscribed = %s"END"
|
||||
; when another agent (or another client of the same agent)
|
||||
; subscribes to the same SMP queue on the server
|
||||
|
||||
connDown = %s"DOWN"
|
||||
; lost connection (e.g. because of Internet connectivity or server is down)
|
||||
|
||||
connUp = %s"UP"
|
||||
; restored connection
|
||||
|
||||
joinCmd = %s"JOIN" SP connectionRequest SP connInfo [SP %s"NO_REPLY"] [SP %s"NO_ACK"]
|
||||
; `connectionRequest` and `connInfo` are defined below
|
||||
; response is `connected` or `error`
|
||||
; parameters NO_REPLY and NO_ACK are currently not supported
|
||||
|
||||
suspendCmd = %s"OFF" ; can be sent by either party, response `ok` or `error`
|
||||
|
||||
deleteCmd = %s"DEL" ; can be sent by either party, response `ok` or `error`
|
||||
|
||||
sendCmd = %s"SEND" SP msgBody
|
||||
; send syntax is similar to that of SMP protocol, but it is wrapped in SMP message
|
||||
msgBody = stringMsg | binaryMsg
|
||||
stringMsg = ":" string ; until CRLF in the transmission
|
||||
string = *(%x01-09 / %x0B-0C / %x0E-FF %) ; any characters other than NUL, CR and LF
|
||||
binaryMsg = size CRLF msgBody CRLF ; the last CRLF is in addition to CRLF in the transmission
|
||||
size = 1*DIGIT ; size in bytes
|
||||
msgBody = *OCTET ; any content of specified size - safe for binary
|
||||
|
||||
messageId = %s"MID" SP agentMsgId
|
||||
|
||||
sent = %s"SENT" SP agentMsgId
|
||||
|
||||
messageError = %s"MERR" SP agentMsgId SP <errorType>
|
||||
|
||||
message = %s"MSG" SP msgIntegrity SP recipientMeta SP brokerMeta SP senderMeta SP binaryMsg
|
||||
recipientMeta = %s"R=" agentMsgId "," agentTimestamp ; receiving agent message metadata
|
||||
brokerMeta = %s"B=" brokerMsgId "," brokerTimestamp ; broker (server) message metadata
|
||||
senderMeta = %s"S=" agentMsgId ; sending agent message ID
|
||||
brokerMsgId = encoded
|
||||
brokerTimestamp = <date-time>
|
||||
msgIntegrity = ok / msgIntegrityError
|
||||
|
||||
msgIntegrityError = %s"ERR" SP msgIntegrityErrorType
|
||||
msgIntegrityErrorType = skippedMsgErr / badMsgIdErr / badHashErr
|
||||
|
||||
skippedMsgErr = %s"NO_ID" SP missingFromMsgId SP missingToMsgId
|
||||
badMsgIdErr = %s"ID" SP previousMsgId ; ID is lower than the previous
|
||||
badHashErr = %s"HASH"
|
||||
|
||||
missingFromMsgId = agentMsgId
|
||||
missingToMsgId = agentMsgId
|
||||
previousMsgId = agentMsgId
|
||||
|
||||
acknowledgeCmd = %s"ACK" SP agentMsgId ; ID assigned by receiving agent (in MSG "R")
|
||||
|
||||
received = %s"RCVD" SP agentMsgId SP msgIntegrity
|
||||
; ID assigned by sending agent (in SENT response)
|
||||
; currently not implemented
|
||||
|
||||
msgStatus = ok | error
|
||||
|
||||
ok = %s"OK"
|
||||
|
||||
error = %s"ERR" SP <errorType>
|
||||
```
|
||||
|
||||
### Client commands and server responses
|
||||
|
||||
#### NEW command and INV response
|
||||
|
||||
`NEW` command is used to create a connection and a connection request to be sent out-of-band to another protocol user (the joining party). It should be used by the client of the agent that initiates creating a duplex connection (the initiating party).
|
||||
|
||||
`INV` response is sent by the agent to the client of the initiating party.
|
||||
|
||||
`NEW` command has `connectionMode` parameter to define the connection mode - to be used to communicate with a single contact (invitation mode, `connectionMode` is `INV`) or to accept connection requests from anybody (contact mode, `connectionMode` is `CON`). The type of connection request is determined by `connectionMode` parameter.
|
||||
|
||||
#### JOIN command
|
||||
|
||||
It is used to create a connection and accept the connection request received out-of-band. It should be used by the client of the agent that accepts the connection (the joining party).
|
||||
|
||||
#### CONF notification and LET command
|
||||
|
||||
When the joining party uses `JOIN` command to accept connection invitation created with `NEW INV` command, the initiating party will receive `CONF` notification with some numeric identifier and an additional binary information, that can be used to identify the joining party or for any other purpose.
|
||||
|
||||
To continue with the connection the initiating party should use `LET` command.
|
||||
|
||||
#### REQ notification and ACPT command
|
||||
|
||||
When the joining party uses `JOIN` command to connect to the contact created with `NEW CON` command, the initiating party will receive `REQ` notification with some numeric identifier and an additional binary information, that can be used to identify the joining party or for any other purpose.
|
||||
|
||||
To continue with the connection the party that created the contact should use `ACPT` command.
|
||||
|
||||
#### INFO and CON notifications
|
||||
|
||||
After the initiating party proceeds with the connection using `ACPT` command, the joining party will receive `INFO` notification that can be used to identify the initiating party or for any other purpose.
|
||||
|
||||
Once the connection is established and ready to accept client messages, both agents will send `CON` notification to their clients.
|
||||
|
||||
#### SUB command
|
||||
|
||||
This command can be used by the client to resume receiving messages from the connection that was created in another TCP/client session. Agent response to this command can be `OK` or `ERR` in case connection does not exist (or can only be used to send connections - e.g. when the reply queue was not created).
|
||||
|
||||
#### SEND command and MID, SENT, RCVD and MERR responses
|
||||
|
||||
`SEND` command is used by the client to send messages.
|
||||
|
||||
`MID` response with the message ID (the sequential message number that includes both sent and received messages in the connection) is sent to the client to confirm that the message is accepted by the agent, before it is sent to the SMP server.
|
||||
|
||||
`SENT` notification is sent by the agent to confirm that the message was delivered to at least one of SMP servers. This notification contains the same message ID as `MID` notification. `SENT` notification, depending on network availability, can be sent at any time later, potentially in the next client session.
|
||||
|
||||
`RCVD` notification is sent by the agent when it receives `ACK` message from the receiving agent. This notification contains reception status, only one successful notification will be sent, and multiple error notifications will be sent in case `ACK` had error status.
|
||||
|
||||
In case of the failure to send the message for any other reason than network connection or message queue quota - e.g. authentication error (`ERR AUTH`) or syntax error (`ERR CMD error`), the agent will send to the client `MERR` notification with the message ID, and this message delivery will no longer be attempted to this SMP queue.
|
||||
|
||||
#### MSG notification
|
||||
|
||||
It is sent by the agent to the client when agent receives the message from the SMP server. It has message ID and timestamp from both the receiving and sending agents and from SMP server:
|
||||
- recipient agent ID is intended to be used to refer to the message in the future.
|
||||
- sender agent ID is intended to be used to identify any missed / skipped message(s)
|
||||
- broker ID should be used to detect duplicate deliveries (it would happen if TCP connection is lost before the message is acknowledged by the agent - see [SMP protocol](./simplex-messaging.md))
|
||||
|
||||
#### END notification
|
||||
|
||||
It is sent by the agent to the client when agent receives SMP protocol `END` notification from SMP server. It indicates that another agent has subscribed to the same SMP queue on the server and the server terminated the subscription of the current agent.
|
||||
|
||||
#### DOWN and UP notifications
|
||||
|
||||
These notifications are sent when server or network connection is, respectively, `DOWN` or back `UP`.
|
||||
|
||||
All the subscriptions made in the current client session will be automatically resumed when `UP` notification is received.
|
||||
|
||||
#### OFF command
|
||||
|
||||
It is used to suspend the receiving SMP queue - sender will no longer be able to send the messages to the connection, but the recipient can retrieve the remaining messages. Agent response to this command can be `OK` or `ERR`. This command is irreversible.
|
||||
|
||||
#### DEL command
|
||||
|
||||
It is used to delete the connection and all messages in it, as well as the receiving SMP queue and all messages in it that were remaining on the server. Agent response to this command can be `OK` or `ERR`. This command is irreversible.
|
||||
|
||||
## Connection request
|
||||
|
||||
Connection request `connectionRequest` is generated by SMP agent in response to `newCmd` command (`"NEW"`), used by another party user with `joinCmd` command (`"JOIN"`), and then another connection request is sent by the agent in `replyQueueMsg` and used by the first party agent to connect to the reply queue (the second part of the process is invisible to the users).
|
||||
|
||||
Connection request syntax:
|
||||
Connection link syntax:
|
||||
|
||||
```
|
||||
connectionRequest = connectionScheme "/" connReqType "#/?smp=" smpQueues "&e2e=" e2eEncryption
|
||||
connReqType = %s"invitation" / %s"contact"
|
||||
; this parameter has the same meaning as connectionMode in agent commands
|
||||
; `NEW INV` creates `invitation` connection request, `NEW CON` - `contact`
|
||||
connectionLink = connectionScheme "/" connLinkType "#/?smp=" smpQueues "&e2e=" e2eEncryption
|
||||
connLinkType = %s"invitation" / %s"contact"
|
||||
connectionScheme = (%s"https://" clientAppServer) | %s"simplex:"
|
||||
clientAppServer = hostname [ ":" port ]
|
||||
; client app server, e.g. simplex.chat
|
||||
@@ -407,12 +364,112 @@ smpQueue = <URL-encoded queueURI defined in SMP protocol>
|
||||
|
||||
All parameters are passed via URI hash to avoid sending them to the server (in case "https" scheme is used) - they can be used by the client-side code and processed by the client application. Parameters `smp` and `e2e` can be present in any order, any unknown additional parameters SHOULD be ignored.
|
||||
|
||||
`clientAppServer` is not an SMP server - it is a server that shows the instruction on how to download the client app that will connect using this connection request. This server can also host a mobile or desktop app manifest so that this link is opened directly in the app if it is installed on the device.
|
||||
`clientAppServer` is not an SMP server - it is a server that shows the instruction on how to download the client app that will connect using this connection link. This server can also host a mobile or desktop app manifest so that this link is opened directly in the app if it is installed on the device.
|
||||
|
||||
"simplex" URI scheme in `connectionProtocol` can be used instead of client app server, to connect without creating any web traffic. Client apps MUST support this URI scheme.
|
||||
|
||||
See SMP protocol [out-of-band messages](./simplex-messaging.md#out-of-band-messages) for syntax of `queueURI`.
|
||||
|
||||
## Appendix A: SMP agent API
|
||||
|
||||
The exact specification of agent library API and of the events that the agent sends to the client application is out of scope of the protocol specification.
|
||||
|
||||
The list of some of the API functions and events below is supported by the reference implementation, and they are likely to be required by the client applications.
|
||||
|
||||
### API functions
|
||||
|
||||
The list of APIs below is not exhaustive and provided for information only. Please consult the source code for more information.
|
||||
|
||||
#### Create conection
|
||||
|
||||
`createConnection` api is used to create a connection - it returns the connection link that should be sent out-of-band to another protocol user (the joining party). It should be used by the client of the agent that initiates creating a duplex connection (the initiating party).
|
||||
|
||||
This api is also used to create a contact address - a special connection that can be used by multiple people to connect to the user.
|
||||
|
||||
Some communication scenarios may require fault-tolerant mechanism of creating connections that retries on network failures and continue retrying after the client is restarted. Such asynchronous API would return its result via `INV` event once it succeeds.
|
||||
|
||||
#### Join connection
|
||||
|
||||
`joinConnection` is used to create a connection record and accept the connection invitation received out-of-band. It should be used by the client of the agent that accepts the connection (the joining party).
|
||||
|
||||
This api can also be required as asynchronous, in which case `OK` event will be dispatched to the client to indicate the success or `ERR` in case it permanently failed (e.g., in case connection was deleted by another party).
|
||||
|
||||
#### Allow connection
|
||||
|
||||
Once the client receives `CONF` event, it should use synchronous `allowConnection` api to proceed with the connection (both for the [standard](#duplex-connection-procedure) and for the [fast duplex procedure](#fast-duplex-connection-procedure)).
|
||||
|
||||
In case this API is used as asynchronous it will return its result via `OK` or `ERR` event.
|
||||
|
||||
#### Accept and reject connection requests
|
||||
|
||||
Connection requests are delivered to the client application via `REQ` event.
|
||||
|
||||
Client can `acceptContact` and `rejectContact`, with `OK` and `ERR` events in case of asynchronous calls.
|
||||
|
||||
#### Send message
|
||||
|
||||
`sendMessage` api is always asynchronous. The api call returns message ID, `SENT` event once the message is sent to the server, `MWARN` event in case of temporary delivery failure that can be resolved by the user (e.g., by connecting via Tor or by upgrading the client) and `MERR` in case of permanent delivery failure.
|
||||
|
||||
#### Acknowledge received message
|
||||
|
||||
Messages are delivered to the client application via `MSG` event.
|
||||
|
||||
Client application must always `ackMessage` to receive the next one - failure to call it in reference implementation will prevent the delivery of subsequent messages until the client reconnects to the server.
|
||||
|
||||
This api is also used to acknowledge message delivery to the sending party - that party client application will receive `RCVD` event.
|
||||
|
||||
#### Subscribe connection
|
||||
|
||||
`subscribeConnection` api is used by the client to resume receiving messages from the connection that was created in another TCP/client session.
|
||||
|
||||
#### Get notification message
|
||||
|
||||
`getNotificationMessage` is used by push notification subsystem of the client application to receive the message from a specific messaging queue mentioned in the notification. The client application would receive `MSG` and any other events from the agent, and then `MSGNTF` event once the message related to this notification is received.
|
||||
|
||||
#### Rotate message queue to another server
|
||||
|
||||
`switchConnection` api is used to rotate connection queues to another messaging server.
|
||||
|
||||
#### Renegotiate e2e encryption
|
||||
|
||||
`synchronizeRatchet` api is used to re-negotiate double ratchet encryption for the connection.
|
||||
|
||||
#### Delete connection
|
||||
|
||||
`deleteConnection` api is used to delete connection. In case of asynchronous call, the connection deletion will be confirmed with `DEL_RCVQ` and `DEL_CONN` events.
|
||||
|
||||
#### Suspend connection
|
||||
|
||||
`suspendConnection` api is used to prevent any further messages delivered to the connection without deleting it.
|
||||
|
||||
### API events
|
||||
|
||||
Agent API uses these events dispatch to notify client application about events related to the connections:
|
||||
- `INV` - connection invitation or connection address URI after connection is created.
|
||||
- `CONF` - confirmation that connection is accepted by another party. When the accepting party uses `joinConnection` api to accept connection invitation, the initiating party will receive `CONF` notification with some identifier and additional information from the accepting party (e.g., profile). To continue the connection the initiating party client should use `allowConnection` api.
|
||||
- `REQ` - connection request is sent when another party uses `joinConnection` api with contact address. The client application can use `acceptContact` or `rejectContact` api.
|
||||
- `INFO` - information from the party that initiated the connection with `createConnection` sent to the party accepting the connection with `joinConnection`.
|
||||
- `CON` - notification that connection is established sent to both parties of the connection.
|
||||
- `END` - notification that connection subscription is terminated when another client subscribed to the same messaging queue.
|
||||
- `DOWN` - notification that connection server is temporarily unavailable.
|
||||
- `UP` - notification that the subscriptions made in the current client session are resumed after the server became available.
|
||||
- `SWITCH` - notification about queue rotation process.
|
||||
- `RSYNC` - notification about e2e encryption re-negotiation process.
|
||||
- `SENT` - notification to confirm that the message was delivered to at least one of SMP servers. This notification contains the same message ID as returned to `sendMessage` api. `SENT` notification, depending on network availability, can be sent at any time later, potentially in the next client session.
|
||||
- `MWARN` - temporary delivery failure that can be resolved by the user (e.g., by connecting via Tor or by upgrading the client).
|
||||
- `MERR` - notification about permanent message delivery failure.
|
||||
- `MERRS` - notification about permanent message delivery failure for multiple messages (e.g., when multiple messages expire).
|
||||
- `MSG` - sent when agent receives the message from the SMP server.
|
||||
- `MSGNTF` - sent after agent received and processed the message referenced in the push notification.
|
||||
- `RCVD` - notification confirming message receipt by another party.
|
||||
- `QCONT` - notification that the agent continued sending messages after queue capacity was exceeded and recipient received all messages.
|
||||
- `DEL_RCVQ` - confirmation that message queue was deleted.
|
||||
- `DEL_CONN` - confirmation that connection was deleted.
|
||||
- `OK` - confirmation that asynchronous api call was successful.
|
||||
- `ERR` - error of asynchronous api call or some other error event.
|
||||
|
||||
This list of events is not exhaustive and provided for information only. Please consult the source code for more information.
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/End-to-end_encryption
|
||||
[2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack
|
||||
[3]: https://tools.ietf.org/html/rfc5234
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
sequenceDiagram
|
||||
participant A as Alice
|
||||
participant AA as Alice's<br>agent
|
||||
participant AS as Alice's<br>server
|
||||
participant BS as Bob's<br>server
|
||||
participant BA as Bob's<br>agent
|
||||
participant B as Bob
|
||||
|
||||
note over AA, BA: status (receive/send): NONE/NONE
|
||||
|
||||
note over A, AA: 1. request connection<br>from agent
|
||||
A ->> AA: createConnection
|
||||
|
||||
note over AA, AS: 2. create Alice's SMP queue
|
||||
AA ->> AS: NEW: create SMP queue<br>allow sender to secure
|
||||
AS ->> AA: IDS: SMP queue IDs
|
||||
note over AA: status: NEW/NONE
|
||||
|
||||
AA ->> A: INV: invitation<br>to connect
|
||||
|
||||
note over A, B: 3. out-of-band invitation
|
||||
A ->> B: OOB: invitation to connect
|
||||
|
||||
note over BA, B: 4. accept connection
|
||||
B ->> BA: joinConnection:<br>via invitation info
|
||||
note over BA: status: NONE/NEW
|
||||
|
||||
note over BA, AS: 5. secure Alice's SMP queue
|
||||
BA ->> AS: SKEY: secure queue (this command needs to be proxied)
|
||||
note over BA: status: NONE/SECURED
|
||||
|
||||
note over BA, BS: 6. create Bob's SMP queue
|
||||
BA ->> BS: NEW: create SMP queue<br>allow sender to secure
|
||||
BS ->> BA: IDS: SMP queue IDs
|
||||
note over BA: status: NEW/SECURED
|
||||
|
||||
note over BA, AA: 7. confirm Alice's SMP queue
|
||||
BA ->> AS: SEND: Bob's info without sender's key (SMP confirmation with reply queues)
|
||||
note over BA: status: NEW/CONFIRMED
|
||||
|
||||
AS ->> AA: MSG: Bob's info without<br>sender server key
|
||||
note over AA: status: CONFIRMED/NEW
|
||||
AA ->> AS: ACK: confirm message
|
||||
AA ->> A: CONF: connection request ID<br>and Bob's info
|
||||
A -> AA: allowConnection: accept connection request,<br>send Alice's info
|
||||
|
||||
note over AA, BS: 8. secure Bob's SMP queue
|
||||
AA ->> BS: SKEY: secure queue (this command needs to be proxied)
|
||||
note over BA: status: CONFIRMED/SECURED
|
||||
|
||||
AA ->> BS: SEND: Alice's info without sender's server key (SMP confirmation without reply queues)
|
||||
note over AA: status: CONFIRMED/CONFIRMED
|
||||
|
||||
note over AA, A: 9. notify Alice<br>about connection success<br>(no HELLO needed in v6)
|
||||
AA ->> A: CON: connected
|
||||
note over AA: status: ACTIVE/ACTIVE
|
||||
|
||||
note over BA, B: 10. notify Bob<br>about connection success
|
||||
BS ->> BA: MSG: Alice's info without<br>sender's server key
|
||||
note over BA: status: CONFIRMED/CONFIRMED
|
||||
BA ->> B: INFO: Alice's info
|
||||
BA ->> BS: ACK: confirm message
|
||||
|
||||
BA ->> B: CON: connected
|
||||
note over BA: status: ACTIVE/ACTIVE
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -1,71 +0,0 @@
|
||||
sequenceDiagram
|
||||
participant A as Alice
|
||||
participant AA as Alice's<br>agent
|
||||
participant AS as Alice's<br>server
|
||||
participant BS as Bob's<br>server
|
||||
participant BA as Bob's<br>agent
|
||||
participant B as Bob
|
||||
|
||||
note over AA, BA: status (receive/send): NONE/NONE
|
||||
|
||||
note over A, AA: 1. request connection<br>from agent
|
||||
A ->> AA: NEW: create<br>duplex connection
|
||||
|
||||
note over AA, AS: 2. create Alice's SMP queue
|
||||
AA ->> AS: NEW: create SMP queue
|
||||
AS ->> AA: IDS: SMP queue IDs
|
||||
note over AA: status: NEW/NONE
|
||||
|
||||
AA ->> A: INV: invitation<br>to connect
|
||||
|
||||
note over A, B: 3. out-of-band invitation
|
||||
A ->> B: OOB: invitation to connect
|
||||
|
||||
note over BA, B: 4. accept connection
|
||||
B ->> BA: JOIN:<br>via invitation info
|
||||
note over BA: status: NONE/NEW
|
||||
|
||||
note over BA, BS: 5. create Bob's SMP queue
|
||||
BA ->> BS: NEW: create SMP queue
|
||||
BS ->> BA: IDS: SMP queue IDs
|
||||
note over BA: status: NEW/NEW
|
||||
|
||||
note over BA, AA: 6. establish Alice's SMP queue
|
||||
BA ->> AS: SEND: Bob's info and sender server key (SMP confirmation with reply queues)
|
||||
note over BA: status: NEW/CONFIRMED
|
||||
|
||||
AS ->> AA: MSG: Bob's info and<br>sender server key
|
||||
note over AA: status: CONFIRMED/NONE
|
||||
AA ->> AS: ACK: confirm message
|
||||
AA ->> A: CONF: connection request ID<br>and Bob's info
|
||||
A ->> AA: LET: accept connection request,<br>send Alice's info
|
||||
AA ->> AS: KEY: secure queue
|
||||
note over AA: status: SECURED/NONE
|
||||
|
||||
AA ->> BS: SEND: Alice's info and sender's server key (SMP confirmation without reply queues)
|
||||
note over AA: status: SECURED/CONFIRMED
|
||||
|
||||
BS ->> BA: MSG: Alice's info and<br>sender's server key
|
||||
note over BA: status: CONFIRMED/CONFIRMED
|
||||
BA ->> B: INFO: Alice's info
|
||||
BA ->> BS: ACK: confirm message
|
||||
BA ->> BS: KEY: secure queue
|
||||
note over BA: status: SECURED/CONFIRMED
|
||||
|
||||
BA ->> AS: SEND: HELLO: only needs to be sent once in v2
|
||||
|
||||
note over BA: status: SECURED/ACTIVE
|
||||
note over BA, B: 7a. notify Bob<br>about connection success
|
||||
BA ->> B: CON: connected
|
||||
|
||||
AS ->> AA: MSG: HELLO: Alice's agent<br>knows Bob can send
|
||||
note over AA: status: SECURED/ACTIVE
|
||||
AA ->> AS: ACK: confirm message
|
||||
note over A, AA: 7a. notify Alice<br>about connection success
|
||||
AA ->> A: CON: connected
|
||||
|
||||
AA ->> BS: SEND: HELLO: only needs to be sent once in v2
|
||||
note over AA: status: ACTIVE/ACTIVE
|
||||
BS ->> BA: MSG: HELLO: Bob's agent<br>knows Alice can send
|
||||
note over BA: status: ACTIVE/ACTIVE
|
||||
BA ->> BS: ACK: confirm message
|
||||
@@ -8,8 +8,8 @@ sequenceDiagram
|
||||
|
||||
note over AA, BA: status (receive/send): NONE/NONE
|
||||
|
||||
note over A, AA: 1. request connection from agent
|
||||
A ->> AA: NEW: create<br>duplex connection
|
||||
note over A, AA: 1. request connection<br>from agent
|
||||
A ->> AA: createConnection
|
||||
|
||||
note over AA, AS: 2. create Alice's SMP queue
|
||||
AA ->> AS: NEW: create SMP queue
|
||||
@@ -17,63 +17,58 @@ sequenceDiagram
|
||||
note over AA: status: NEW/NONE
|
||||
|
||||
AA ->> A: INV: invitation<br>to connect
|
||||
note over AA: status: PENDING/NONE
|
||||
|
||||
note over A, B: 3. out-of-band invitation
|
||||
A ->> B: OOB: invitation to connect
|
||||
|
||||
note over BA, B: 4. accept connection
|
||||
B ->> BA: JOIN:<br>via invitation info
|
||||
B ->> BA: joinConnection:<br>via invitation info
|
||||
note over BA: status: NONE/NEW
|
||||
|
||||
note over BA, AA: 5. establish Alice's SMP queue
|
||||
BA ->> AS: SEND: Bob's info and sender server key (SMP confirmation)
|
||||
note over BA: status: NONE/CONFIRMED
|
||||
activate BA
|
||||
note over BA, BS: 5. create Bob's SMP queue
|
||||
BA ->> BS: NEW: create SMP queue
|
||||
BS ->> BA: IDS: SMP queue IDs
|
||||
note over BA: status: NEW/NEW
|
||||
|
||||
note over BA, AA: 6. confirm Alice's SMP queue
|
||||
BA ->> AS: SEND: Bob's info and sender server key (SMP confirmation with reply queues)
|
||||
note over BA: status: NEW/CONFIRMED
|
||||
|
||||
AS ->> AA: MSG: Bob's info and<br>sender server key
|
||||
note over AA: status: CONFIRMED/NONE
|
||||
AA ->> AS: ACK: confirm message
|
||||
AA ->> A: CONF: connection request ID<br>and Bob's info
|
||||
A ->> AA: LET: accept connection request,<br>send Alice's info
|
||||
A ->> AA: allowConnection: accept connection request,<br>send Alice's info
|
||||
AA ->> AS: KEY: secure queue
|
||||
note over AA: status: SECURED/NONE
|
||||
|
||||
BA ->> AS: SEND: HELLO: try sending until successful
|
||||
deactivate BA
|
||||
note over BA: status: NONE/ACTIVE
|
||||
AS ->> AA: MSG: HELLO: Alice's agent<br>knows Bob can send
|
||||
note over AA: status: ACTIVE/NONE
|
||||
AA ->> AS: ACK: confirm message
|
||||
AA ->> BS: SEND: Alice's info and sender's server key (SMP confirmation without reply queues)
|
||||
note over AA: status: SECURED/CONFIRMED
|
||||
|
||||
note over BA, BS: 6. create Bob's SMP queue
|
||||
BA ->> BS: NEW: create SMP queue
|
||||
BS ->> BA: IDS: SMP queue IDs
|
||||
note over BA: status: NEW/ACTIVE
|
||||
|
||||
note over AA, BA: 7. establish Bob's SMP queue
|
||||
BA ->> AS: SEND: REPLY: invitation to the connect
|
||||
note over BA: status: PENDING/ACTIVE
|
||||
AS ->> AA: MSG: REPLY: invitation<br>to connect
|
||||
note over AA: status: ACTIVE/NEW
|
||||
AA ->> AS: ACK: confirm message
|
||||
|
||||
AA ->> BS: SEND: Alice's info and sender's server key
|
||||
note over AA: status: ACTIVE/CONFIRMED
|
||||
activate AA
|
||||
note over BA, AA: 7. confirm Bob's SMP queue
|
||||
BS ->> BA: MSG: Alice's info and<br>sender's server key
|
||||
note over BA: status: CONFIRMED/ACTIVE
|
||||
note over BA: status: CONFIRMED/CONFIRMED
|
||||
BA ->> B: INFO: Alice's info
|
||||
BA ->> BS: ACK: confirm message
|
||||
BA ->> BS: KEY: secure queue
|
||||
note over BA: status: SECURED/CONFIRMED
|
||||
|
||||
BA ->> AS: SEND: HELLO message
|
||||
|
||||
note over BA: status: SECURED/ACTIVE
|
||||
|
||||
AA ->> BS: SEND: HELLO: try sending until successful
|
||||
deactivate AA
|
||||
AS ->> AA: MSG: HELLO: Alice's agent<br>knows Bob can send
|
||||
note over AA: status: SECURED/ACTIVE
|
||||
AA ->> AS: ACK: confirm message
|
||||
AA ->> BS: SEND: HELLO
|
||||
|
||||
note over A, AA: 8. notify Alice<br>about connection success
|
||||
AA ->> A: CON: connected
|
||||
note over AA: status: ACTIVE/ACTIVE
|
||||
|
||||
BS ->> BA: MSG: HELLO: Bob's agent<br>knows Alice can send
|
||||
note over BA: status: ACTIVE/ACTIVE
|
||||
BA ->> BS: ACK: confirm message
|
||||
|
||||
note over A, B: 8. notify users about connection success
|
||||
AA ->> A: CON: connected
|
||||
note over BA, B: 9. notify Bob<br>about connection success
|
||||
BA ->> B: CON: connected
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,17 @@
|
||||
sequenceDiagram
|
||||
participant A as Alice
|
||||
participant R as Current server<br>that has A's<br>receive queue
|
||||
participant R' as New server<br>that has the new A's<br>receive queue
|
||||
participant S as Server<br>that has A's send queue<br>(B's receive queue)
|
||||
participant B as Bob
|
||||
|
||||
A ->> R': NEW: create new queue<br>(allow SKEY)
|
||||
A ->> S: SEND: QADD (R'): send address<br>of the new queue(s)
|
||||
S ->> B: MSG: QADD (R')
|
||||
B ->> R': SKEY: secure new queue
|
||||
B ->> R': SEND: QTEST
|
||||
R' ->> A: MSG: QTEST
|
||||
A ->> R: DEL: delete the old queue
|
||||
B ->> R': SEND: send messages to the new queue
|
||||
R' ->> A: MSG: receive messages from the new queue
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,21 @@
|
||||
sequenceDiagram
|
||||
participant A as Alice
|
||||
participant R as Current server<br>that has A's<br>receive queue
|
||||
participant R' as New server<br>that has the new A's<br>receive queue
|
||||
participant S as Server<br>that has A's send queue<br>(B's receive queue)
|
||||
participant B as Bob
|
||||
|
||||
A ->> R': NEW: create new queue
|
||||
A ->> S: SEND: QADD (R'): send address<br>of the new queue(s)
|
||||
S ->> B: MSG: QADD (R')
|
||||
B ->> R: SEND: QKEY (R'): sender's key<br>for the new queue(s)
|
||||
R ->> A: MSG: QKEY(R')
|
||||
A ->> R': KEY: secure new queue
|
||||
A ->> S: SEND: QUSE (R'): instruction to use new queue(s)
|
||||
S ->> B: MSG: QUSE (R')
|
||||
B ->> R': SEND: QTEST
|
||||
R' ->> A: MSG: QTEST
|
||||
A ->> R: DEL: delete the old queue
|
||||
B ->> R': SEND: send messages to the new queue
|
||||
R' ->> A: MSG: receive messages from the new queue
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,30 @@
|
||||
sequenceDiagram
|
||||
participant M as mobile app
|
||||
participant C as chat core
|
||||
participant A as agent
|
||||
participant P as push server
|
||||
participant APN as APN
|
||||
|
||||
note over M, APN: get device token
|
||||
M ->> APN: registerForRemoteNotifications()
|
||||
APN ->> M: device token
|
||||
|
||||
note over M, P: register device token with push server
|
||||
M ->> C: /_ntf register <token>
|
||||
C ->> A: registerNtfToken(<token>)
|
||||
A ->> P: TNEW
|
||||
P ->> A: ID (tokenId)
|
||||
A ->> C: registered
|
||||
C ->> M: registered
|
||||
|
||||
note over M, APN: verify device token
|
||||
P ->> APN: E2E encrypted code<br>in background<br>notification
|
||||
APN ->> M: deliver background notification with e2ee verification token
|
||||
M ->> C: /_ntf verify <e2ee code>
|
||||
C ->> A: verifyNtfToken(<e2ee code>)
|
||||
A ->> P: TVFY code
|
||||
P ->> A: OK / ERR
|
||||
A ->> C: verified
|
||||
C ->> M: verified
|
||||
|
||||
note over M, APN: now token ID can be used
|
||||
@@ -1,30 +1,26 @@
|
||||
sequenceDiagram
|
||||
participant M as mobile app
|
||||
participant C as chat core
|
||||
participant C as client app
|
||||
participant A as agent
|
||||
participant P as push server
|
||||
participant APN as APN
|
||||
participant P as SimpleX<br>Notification<br>Server
|
||||
participant APN as Apple<br>Push Notifications<br>Server
|
||||
|
||||
note over M, APN: get device token
|
||||
M ->> APN: registerForRemoteNotifications()
|
||||
APN ->> M: device token
|
||||
note over C, APN: get device token
|
||||
C ->> APN: registerForRemoteNotifications()
|
||||
APN ->> C: device token
|
||||
|
||||
note over M, P: register device token with push server
|
||||
M ->> C: /_ntf register <token>
|
||||
C ->> A: registerNtfToken(<token>)
|
||||
note over C, P: register device token with push server
|
||||
C ->> A: registerToken
|
||||
A ->> P: TNEW
|
||||
P ->> A: ID (tokenId)
|
||||
A ->> C: registered
|
||||
C ->> M: registered
|
||||
|
||||
note over M, APN: verify device token
|
||||
note over C, APN: verify device token
|
||||
P ->> APN: E2E encrypted code<br>in background<br>notification
|
||||
APN ->> M: deliver background notification with e2ee verification token
|
||||
M ->> C: /_ntf verify <e2ee code>
|
||||
C ->> A: verifyNtfToken(<e2ee code>)
|
||||
APN ->> C: deliver background notification with e2ee verification token
|
||||
C ->> A: verifyToken<br>(<e2ee code>)
|
||||
A ->> P: TVFY code
|
||||
P ->> A: OK / ERR
|
||||
A ->> C: verified
|
||||
C ->> M: verified
|
||||
|
||||
note over M, APN: now token ID can be used
|
||||
note over C, APN: now token ID can be used
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,40 @@
|
||||
sequenceDiagram
|
||||
participant M as mobile app
|
||||
participant C as chat core
|
||||
participant A as agent
|
||||
participant S as SMP server
|
||||
participant N as NTF server
|
||||
participant APN as APN
|
||||
|
||||
note over M, APN: register subscription
|
||||
|
||||
alt register existing
|
||||
M -->> A: on /_ntf register, for subscribed queues
|
||||
else create new connection
|
||||
A -->> S: NEW / JOIN
|
||||
note over A, S: ...<br>Connection handshake<br>...
|
||||
S -->> A: CON
|
||||
end
|
||||
A ->> S: NKEY nKey
|
||||
S ->> A: NID nId
|
||||
A ->> N: SNEW tknId dhKey (smpServer, nId, nKey)
|
||||
N ->> A: ID subId dhKey
|
||||
N ->> S: NSUB nId
|
||||
S ->> N: OK [/ NMSG]
|
||||
|
||||
note over M, APN: notify about message
|
||||
|
||||
S ->> N: NMSG
|
||||
N ->> APN: APNSMutableContent<br>ntfQueue, nonce
|
||||
APN ->> M: UNMutableNotificationContent
|
||||
note over M, S: ...<br>Client awaken, message is received<br>...
|
||||
S ->> M: message
|
||||
note over M: mutate notification
|
||||
|
||||
note over M, APN: change APN token
|
||||
|
||||
APN ->> M: new device token
|
||||
M -->> C: /_ntf_sub update tkn
|
||||
C -->> A: updateNtfToken()
|
||||
A -->> N: TUPD tknId newDeviceToken
|
||||
note over M, N: ...<br>Verify token<br>...
|
||||
@@ -1,17 +1,16 @@
|
||||
sequenceDiagram
|
||||
participant M as mobile app
|
||||
participant C as chat core
|
||||
participant C as client app
|
||||
participant A as agent
|
||||
participant S as SMP server
|
||||
participant N as NTF server
|
||||
participant APN as APN
|
||||
|
||||
note over M, APN: register subscription
|
||||
note over C, APN: register subscription
|
||||
|
||||
alt register existing
|
||||
M -->> A: on /_ntf register, for subscribed queues
|
||||
C -->> A: registerToken
|
||||
else create new connection
|
||||
A -->> S: NEW / JOIN
|
||||
A -->> S: create/joinConnection
|
||||
note over A, S: ...<br>Connection handshake<br>...
|
||||
S -->> A: CON
|
||||
end
|
||||
@@ -20,21 +19,20 @@ sequenceDiagram
|
||||
A ->> N: SNEW tknId dhKey (smpServer, nId, nKey)
|
||||
N ->> A: ID subId dhKey
|
||||
N ->> S: NSUB nId
|
||||
S ->> N: OK [/ NMSG]
|
||||
S ->> N: OK / NMSG:<br>confirm subscription
|
||||
|
||||
note over M, APN: notify about message
|
||||
note over C, APN: notify about message
|
||||
|
||||
S ->> N: NMSG
|
||||
N ->> APN: APNSMutableContent<br>ntfQueue, nonce
|
||||
APN ->> M: UNMutableNotificationContent
|
||||
note over M, S: ...<br>Client awaken, message is received<br>...
|
||||
S ->> M: message
|
||||
note over M: mutate notification
|
||||
APN ->> C: UNMutableNotificationContent
|
||||
note over C, S: ...<br>Client awaken, message is received<br>...
|
||||
S ->> C: message
|
||||
note over C: show notification
|
||||
|
||||
note over M, APN: change APN token
|
||||
note over C, APN: change APN token
|
||||
|
||||
APN ->> M: new device token
|
||||
M -->> C: /_ntf_sub update tkn
|
||||
C -->> A: updateNtfToken()
|
||||
APN ->> C: new device token
|
||||
C -->> A: updateToken()
|
||||
A -->> N: TUPD tknId newDeviceToken
|
||||
note over M, N: ...<br>Verify token<br>...
|
||||
note over C, N: ...<br>Verify token<br>...
|
||||
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,23 @@
|
||||
sequenceDiagram
|
||||
participant B as Bob (sender)
|
||||
participant S as server (queue RID)
|
||||
participant A as Alice (recipient)
|
||||
|
||||
note over A: creating queue<br>("public" key RK<br>for msg retrieval)
|
||||
A ->> S: 1. create queue ("NEW")
|
||||
S ->> A: respond with queue RID and SID ("IDS")
|
||||
|
||||
note over A: out-of-band msg<br>(sender's queue SID<br>and "public" key EK<br>to encrypt msgs)
|
||||
A -->> B: 2. send out-of-band message
|
||||
|
||||
note over B: secure queue<br>(with "public" key SK for<br>sending messages)
|
||||
B ->> S: 3. confirm queue ("SKEY" command authorized with SK)
|
||||
|
||||
note over B: confirm queue<br>(public key<br>for e2e encryption<br>and any optional<br>encrypted info.)
|
||||
B ->> S: 4. confirm queue ("SEND" command authorized with SK)
|
||||
|
||||
S ->> A: 5. deliver Bob's message (MSG)
|
||||
note over A: decrypt message<br>("private" key EK)
|
||||
A ->> S: acknowledge message (ACK)
|
||||
|
||||
note over S: 6. simplex<br>queue RID<br>is ready to use!
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -10,11 +10,13 @@ sequenceDiagram
|
||||
note over A: out-of-band msg<br>(sender's queue SID<br>and "public" key EK<br>to encrypt msgs)
|
||||
A -->> B: 2. send out-of-band message
|
||||
|
||||
note over B: confirm queue<br>("public" key SK for<br>sending messages<br>and any optional<br>info encrypted with<br>"public" key EK)
|
||||
note over B: confirm queue<br>("public" key SK for<br>sending messages,<br>public key for<br>e2e encryption<br>and any optional<br>encrypted info)
|
||||
B ->> S: 3. confirm queue ("SEND" command not signed)
|
||||
|
||||
S ->> A: 4. deliver Bob's message
|
||||
S ->> A: 4. deliver Bob's message (MSG)
|
||||
note over A: decrypt message<br>("private" key EK)
|
||||
A ->> S: acknowledge message (ACK)
|
||||
|
||||
A ->> S: 5. secure queue ("KEY", RK-signed)
|
||||
|
||||
note over S: 6. simplex<br>queue RID<br>is ready to use!
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,18 @@
|
||||
sequenceDiagram
|
||||
participant B as Bob (recipient)
|
||||
participant S as XFTP server(s)
|
||||
|
||||
note over B: having received file description<br>from sender
|
||||
|
||||
loop for each chunk
|
||||
B ->> S: 1a. download chunk ("FGET")
|
||||
S ->> B: send chunk body ("FILE")
|
||||
|
||||
opt
|
||||
B ->> S: 1b. acknowledge chunk reception ("FACK")
|
||||
note over S: delete recipient ID
|
||||
S ->> B: respond with ok ("OK")
|
||||
end
|
||||
end
|
||||
|
||||
note over B: 2. combine chunks into a file<br>3. decrypt file using key from file description<br>4. extract file name and unpad the file<br>5. validate file digest with the file description
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,23 @@
|
||||
sequenceDiagram
|
||||
participant A as Alice (sender)
|
||||
participant S as XFTP server(s)
|
||||
participant B as recipient(s)
|
||||
|
||||
note over A: 1. prepare file:<br>encrypt,<br>split into chunks,<br>generate recipient<br>keys, etc.
|
||||
|
||||
loop for each chunk
|
||||
A ->> S: 2a. register chunk ("FNEW")
|
||||
S ->> A: respond with sender's and recipients' chunk IDs ("SIDS")
|
||||
|
||||
opt
|
||||
A ->> S: 2b. request additional recipient IDs ("FADD")
|
||||
S ->> A: respond with added recipients' chunk IDs ("RIDS")
|
||||
end
|
||||
|
||||
A ->> S: 2c. upload chunk to chosen server ("FPUT")
|
||||
S ->> A: respond with ok ("OK")
|
||||
end
|
||||
|
||||
note over A: 3. prepare file description(s)
|
||||
|
||||
A -->> B: 4. send file description(s) out-of-band
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,44 @@
|
||||
sequenceDiagram
|
||||
participant CI as Controller UI
|
||||
participant CC as Controller Core
|
||||
participant HC as Host Core
|
||||
participant HI as Host UI
|
||||
|
||||
note over CI, HI: 1. Session invitation
|
||||
CI->>CC: "Link a mobile"
|
||||
CC-->>CI: Session invitation URI
|
||||
note over CC: Listen for TCP connection
|
||||
activate CC
|
||||
HI->>HC: Session invitation URI
|
||||
|
||||
note over CI, HI: 2. Establishing TLS connection
|
||||
HC-->>CC: TCP connect
|
||||
note over CC, HC: TLS handshake
|
||||
par
|
||||
note over CC: validate client X509 credentials
|
||||
CC->>CI: session code from tlsUnique
|
||||
CI-->>CC: user confirmation
|
||||
and
|
||||
note over HC: validate server X509 credentials
|
||||
HC->>HI: session code from tlsUnique
|
||||
HI-->>HC: user confirmation
|
||||
end
|
||||
|
||||
note over CI, HI: 3. Session verification and protocol negotiation
|
||||
HC->>CC: host HELLO
|
||||
note over CC: validate version, CA fingerprint
|
||||
alt
|
||||
CC-->>HC: controller ERROR
|
||||
else
|
||||
CC-->>HC: controller HELLO
|
||||
note over CC, HC: update stored keys
|
||||
end
|
||||
deactivate CC
|
||||
|
||||
note over CI, HI: 4. Session operation
|
||||
loop
|
||||
CI->>CC: command
|
||||
CC->>HC: XRCP command
|
||||
HC-->>CC: XRCP response
|
||||
CC-->>CI: response
|
||||
end
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -1,4 +1,4 @@
|
||||
Revision 1, 2022-01-01
|
||||
Revision 2, 2024-06-22
|
||||
|
||||
Evgeny Poberezkin
|
||||
|
||||
@@ -13,19 +13,23 @@ Evgeny Poberezkin
|
||||
- [Technical Details](#technical-details)
|
||||
- [Trust in Servers](#trust-in-servers)
|
||||
- [Client -> Server Communication](#client---server-communication)
|
||||
- [2-hop Onion Message Routing](#2-hop-onion-message-routing)
|
||||
- [SimpleX Messaging Protocol](#simplex-messaging-protocol)
|
||||
- [SimpleX Agents](#simplex-agents)
|
||||
- [Encryption Primitives Used](#encryption-primitives-used)
|
||||
- [Threat model](#threat-model)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
|
||||
|
||||
## Introduction
|
||||
|
||||
#### What is SimpleX
|
||||
|
||||
SimpleX as a whole is a platform upon which applications can be built. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is one such application that also serves as an example and reference application.
|
||||
|
||||
- [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md) (SMP) is a protocol to send messages in one direction to a recipient, relying on a server in-between. The messages are delivered via uni-directional queues created by recipients.
|
||||
- [SimpleX Messaging Protocol](./simplex-messaging.md) (SMP) is a protocol to send messages in one direction to a recipient, relying on a server in-between. The messages are delivered via uni-directional queues created by recipients.
|
||||
|
||||
- SMP protocol allows to send message via a SMP server playing proxy role using 2-hop onion routing (referred to as "private routing" in messaging clients) to protect transport information of the sender (IP address and session) from the server chosen (and possibly controlled) by the recipient.
|
||||
|
||||
- SMP runs over a transport protocol (shown below as TLS) that provides integrity, server authentication, confidentiality, and transport channel binding.
|
||||
|
||||
@@ -35,7 +39,9 @@ SimpleX as a whole is a platform upon which applications can be built. [SimpleX
|
||||
|
||||
- SimpleX Client libraries speak SMP to SimpleX Servers and provide a low-level API not generally intended to be used by applications.
|
||||
|
||||
- SimpleX Agents interface with SimpleX Clients to provide a more high-level API intended to be used by applications. Typically they are embedded as libraries, but are designed so they can also be abstracted into local services.
|
||||
- SimpleX Agents interface with SimpleX Clients to provide a more high-level API intended to be used by applications. Typically they are embedded as libraries, but can also be abstracted into local services.
|
||||
|
||||
- SimpleX Agents communicate with other agents inside e2e encrypted envelopes provided by SMP protocol - the syntax and semantics of the messages exchanged by the agent are defined by [SMP agent protocol](./agent-protocol.md)
|
||||
|
||||
|
||||
*Diagram showing the SimpleX Chat app, with logical layers of the chat application interfacing with a SimpleX Agent library, which in turn interfaces with a SimpleX Client library. The Client library in turn speaks the Messaging Protocol to a SimpleX Server.*
|
||||
@@ -72,10 +78,11 @@ SimpleX as a whole is a platform upon which applications can be built. [SimpleX
|
||||
|
||||
- Low latency: the delay introduced by the network should not be higher than 100ms-1s in addition to the underlying TCP network latency.
|
||||
|
||||
2. Provide better communication security and privacy than the alternative instant messaging solutions. In particular SimpleX provides better privacy of metadata (who talks to whom and when) and better security against active network attackers and malicious servers.
|
||||
2. Provide better communication security and privacy than the alternative instant messaging solutions. In particular SimpleX provides better privacy of metadata (who talks to whom and when) and better security against active network attackers and malicious servers.
|
||||
|
||||
3. Balance user experience with privacy requirements, prioritizing experience of mobile device users.
|
||||
|
||||
|
||||
#### In Comparison
|
||||
|
||||
SimpleX network has a design similar to P2P networks, but unlike most P2P networks it consists of clients and servers without depending on any centralized component.
|
||||
@@ -91,53 +98,73 @@ In comparison to more traditional messaging applications (e.g. WhatsApp, Signal,
|
||||
|
||||
- users can change servers with minimal disruption - even after an in-use server disappears, simply by changing the configuration on which servers the new queues are created.
|
||||
|
||||
|
||||
## Technical Details
|
||||
|
||||
#### Trust in Servers
|
||||
|
||||
Clients communicate directly with servers (but not with other clients) using SimpleX Messaging Protocol (SMP) running over some transport protocol that provides integrity, server authentication, confidentiality, and transport channel binding. By default, we assume this transport protocol is TLS.
|
||||
|
||||
Users use multiple servers, and choose where to receive their messages. Accordingly, they send messages to their communication partners' chosen servers.
|
||||
Users use multiple servers, and choose where to receive their messages. Accordingly, they send messages to their communication partners' chosen servers either directly, if this is a known/trusted server, or via another SMP server providing proxy functionality to protect IP address and session of the sender.
|
||||
|
||||
Although end-to-end encryption is always present, users place a degree of trust in servers. This trust decision is very similar to a user's choice of email provider; however the trust placed in a SimpleX server is significantly less. Notably, there is no re-used identifier or credential between queues on the same (or different) servers. While a user *may* re-use a connection to fetch from multiple queues, or connect to a server from the same IP address, both are choices a user may opt into to break the promise of un-correlatable queues.
|
||||
Although end-to-end encryption is always present, users place a degree of trust in servers they connect to. This trust decision is very similar to a user's choice of email provider; however the trust placed in a SimpleX server is significantly less. Notably, there is no re-used identifier or credential between queues on the same (or different) servers. While a user *may* re-use a transport connection to fetch messages from multiple queues, or connect to a server from the same IP address, both are choices a user may opt into to break the promise of un-correlatable queues.
|
||||
|
||||
Users may trust a server because:
|
||||
|
||||
- They deploy and control the servers themselves from the available open-source code. This has the trade-offs of strong trust in the server but limited metadata obfuscation to a passive network observer. Techniques such as noise traffic, traffic mixing (incurring latency), and using an onion routing transport protocol can mitigate that latter.
|
||||
- They deploy and control the servers themselves from the available open-source code. This has the trade-offs of strong trust in the server but limited metadata obfuscation to a passive network observer. Techniques such as noise traffic, traffic mixing (incurring latency), and using an onion routing transport protocol can mitigate that.
|
||||
|
||||
- They use servers from a trusted commercial provider. The more clients the provider has, the less metadata about the communication times is leaked to the network observers.
|
||||
|
||||
- Users trust their contacts and the servers they chose.
|
||||
By default, servers do not retain access logs, and permanently delete messages and queues when requested. Messages persist only in memory until they cross a threshold of time, typically on the order of days.[0] There is still a risk that a server maliciously records all queues and messages (even though encrypted) sent via the same transport connection to gain a partial knowledge of the user’s communications graph and other meta-data.
|
||||
|
||||
By default, servers do not retain access logs, and permanently delete messages and queues when requested. Messages persist only in memory until they cross a threshold of time, typically on the order of days.[0] There is still a risk that a server maliciously records all queues and messages (even though encrypted) sent via the same transport connection to gain a partial knowledge of the user’s communications graph and other meta-data.
|
||||
SimpleX supports measures (managed transparently to the user at the agent level) to mitigate the trust placed in servers. These include rotating the queues in use between users, noise traffic, supporting overlay networks such as Tor, and isolating traffic to different queues to different transport connections (and Tor circuits, if Tor is used).
|
||||
|
||||
SimpleX supports measures (managed transparently to the user at the agent level) to mitigate the trust placed in servers. These include rotating the queues in use between users, noise traffic, and supporting overlay networks such as Tor.
|
||||
|
||||
[0] While configurable by servers, a minimum value is enforced by the default software. SimpleX Agents provide redundant routing over queues to mitigate against message loss.
|
||||
[0] While configurable by servers, a minimum value is enforced by the default software. SimpleX Agents can provide redundant routing over queues to mitigate against message loss.
|
||||
|
||||
|
||||
#### Client -> Server Communication
|
||||
|
||||
Utilizing TLS grants the SimpleX Messaging Protocol (SMP) server authentication and metadata protection to a passive network observer. But SMP does not rely on the transport protocol for message confidentiality or client authentication. The SMP protocol itself provides end-to-end confidentiality, authentication, and integrity of messages between communicating parties.
|
||||
|
||||
Servers have long-lived, self-signed, offline certificates whose hash is pre-shared with clients over secure channels - either provided with the client library or provided in the secure introduction between clients. The offline certificate signs an online certificate used in the transport protocol handshake. [0]
|
||||
Servers have long-lived, self-signed, offline certificates whose hash is pre-shared with clients over secure channels - either provided with the client library or provided in the secure introduction between clients, as part of the server address. The offline certificate signs an online certificate used in the transport protocol handshake. [0]
|
||||
|
||||
If the transport protocol's confidentiality is broken, incoming and outgoing messages to the server cannot be correlated by message contents. Additionally, because of encryption at the SMP layer, impersonating the server is not sufficient to pass (and therefore correlate) a message from a sender to recipient - the only attack possible is to drop the messages. Only by additionally *compromising* the server can one pass and correlate messages.
|
||||
|
||||
It's important to note that the SMP protocol does not do server authentication. Instead we rely upon the fact that an attacker who tricks the transport protocol into authenticating the server incorrectly cannot do anything with the SMP messages except drop them.
|
||||
|
||||
After the connection is established, the client sends blocks of a fixed size 16Kb, and the server replies with the blocks of the same size to reduce metadata observable to a network adversary. The protocol has been designed to make traffic correlation attacks difficult, adapting ideas from Tor, remailers, and more general onion and mix networks. It does not try to replace Tor though - SimpleX servers can be deployed as onion services and SimpleX clients can communicate with servers over Tor to further improve participants privacy.
|
||||
After the connection is established, the client sends blocks of a fixed size 16KB, and the server replies with the blocks of the same size to reduce metadata observable to a network adversary. The protocol has been designed to make traffic correlation attacks difficult, adapting ideas from Tor, remailers, and more general onion and mix networks. It does not try to replace Tor though - SimpleX servers can be deployed as onion services and SimpleX clients can communicate with servers over Tor to further improve participants privacy.
|
||||
|
||||
By using fixed-size blocks, oversized for the expected content, the vast majority of traffic is uniform in nature. When enough traffic is transiting a server simultaneously, the server acts as a (very) low-latency mix node. We can't rely on this behavior to make a security claim, but we have engineered to take advantage of it when we can. As mentioned, this holds true even if the transport connection is compromised.
|
||||
By using fixed-size blocks, oversized for the expected content, the vast majority of traffic is uniform in nature. When enough traffic is transiting a server simultaneously, the server acts as a low-latency mix node. We can't rely on this behavior to make a security claim, but we have engineered to take advantage of it when we can. As mentioned, this holds true even if the transport connection is compromised.
|
||||
|
||||
The protocol does not protect against attacks targeted at particular users with known identities - e.g., if the attacker wants to prove that two known users are communicating, they can achieve it. At the same time, it substantially complicates large-scale traffic correlation, making determining the real user identities much less effective.
|
||||
The protocol does not protect against attacks targeted at particular users with known identities - e.g., if the attacker wants to prove that two known users are communicating, they can achieve it by observing their local traffic. At the same time, it substantially complicates large-scale traffic correlation, making determining the real user identities much less effective.
|
||||
|
||||
[0] Future versions of SMP may add support for revocation lists of certificates, presently this risk is mitigated by the SMP protocol itself.
|
||||
|
||||
|
||||
#### 2-hop Onion Message Routing
|
||||
|
||||
As SimpleX Messaging Protocol servers providing messaging queues are chosen by the recipients, in case senders connect to these servers directly the server owners (who potentially can be the recipients themselves) can learn senders' IP addresses (if Tor is not used) and which other queues on the same server are accessed by the user in the same transport connection (even if Tor is used).
|
||||
|
||||
While the clients support isolating the messages sent to different queues into different transport connections (and Tor circuits), this is not practical, as it consumes additional traffic and system resources.
|
||||
|
||||
To mitigate this problem SimpleX Messaging Protocol servers support 2-hop onion message routing when the SMP server chosen by the sender forwards the messages to the servers chosen by the recipients, thus protecting both the senders IP addresses and sessions, even if connection isolation and Tor are not used.
|
||||
|
||||
The design of 2-hop onion message routing prevents these potential attacks:
|
||||
|
||||
- MITM by proxy (SMP server that forwards the messages).
|
||||
|
||||
- Identification by the proxy which and how many queues the sender sends messages to (as messages are additionally e2e encrypted between the sender and the destination SMP server).
|
||||
|
||||
- Correlation of messages sent to different queues via the same user session (as random correlation IDs and keys are used for each message).
|
||||
|
||||
See more details about 2-hop onion message routing design in [SimpleX Messaging Protocol](./simplex-messaging.md#proxying-sender-commands)
|
||||
|
||||
Also see [Threat model](#threat-model)
|
||||
|
||||
|
||||
#### SimpleX Messaging Protocol
|
||||
|
||||
SMP is initialized with an in-person or out-of-band introduction message, where Alice provides Bob with details of a server (including IP, port, and hash of the long-lived offline certificate), a queue ID, and Alice's public key for her receiving queue. These introductions are similar to the PANDA key-exchange, in that if observed, the adversary can race to establish the communication channel instead of the intended participant. [0]
|
||||
SMP is initialized with an in-person or out-of-band introduction message, where Alice provides Bob with details of a server (including IP address or host name, port, and hash of the long-lived offline certificate), a queue ID, and Alice's public keys to agree e2e encryption. These introductions are similar to the PANDA key-exchange, in that if observed, the adversary can race to establish the communication channel instead of the intended participant. [0]
|
||||
|
||||
Because queues are uni-directional, Bob provides an identically-formatted introduction message to Alice over Alice's now-established receiving queue.
|
||||
|
||||
@@ -145,6 +172,7 @@ When setting up a queue, the server will create separate sender and recipient qu
|
||||
|
||||
[0] Users can additionally create public 'contact queues' that are only used to receive connection requests.
|
||||
|
||||
|
||||
#### SimpleX Agents
|
||||
|
||||
SimpleX agents provide higher-level operations compared to SimpleX Clients, who are primarily concerned with creating queues and communicating with servers using SMP. Agent operations include:
|
||||
@@ -157,9 +185,10 @@ SimpleX agents provide higher-level operations compared to SimpleX Clients, who
|
||||
|
||||
- Noise traffic
|
||||
|
||||
|
||||
#### Encryption Primitives Used
|
||||
|
||||
- Ed448 to sign/verify commands to SMP servers (Ed25519 is also supported via client/server configuration).
|
||||
- Ed25519 or Curve25519 to authorize/verify commands to SMP servers (authorization algorithm is set via client/server configuration).
|
||||
- Curve25519 for DH exchange to agree:
|
||||
- the shared secret between server and recipient (to encrypt message bodies - it avoids shared cipher-text in sender and recipient traffic)
|
||||
- the shared secret between sender and recipient (to encrypt messages end-to-end in each queue - it avoids shared cipher-text in redundant queues).
|
||||
@@ -170,42 +199,44 @@ SimpleX agents provide higher-level operations compared to SimpleX Clients, who
|
||||
- AES-GCM AEAD cipher,
|
||||
- SHA512-based HKDF for key derivation.
|
||||
|
||||
|
||||
## Threat Model
|
||||
|
||||
#### Global Assumptions
|
||||
|
||||
- A user protects their local database and key material
|
||||
- The user's application is authentic, and no local malware is running
|
||||
- The cryptographic primitives in use are not broken
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of servers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
- The user's client uses 2-hop onion message routing.
|
||||
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
*can:*
|
||||
|
||||
- identify that and when a user is using SimpleX
|
||||
- identify that and when a user is using SimpleX.
|
||||
|
||||
- block SimpleX traffic
|
||||
|
||||
- determine which servers the user communicates with
|
||||
- determine which servers the user receives the messages from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- see who sends messages to the user and who the user sends the messages to
|
||||
- see who sends messages to the user and who the user sends the messages to.
|
||||
|
||||
- determine the servers used by users' contacts.
|
||||
|
||||
#### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- identify who and when is using SimpleX
|
||||
- identify who and when is using SimpleX.
|
||||
|
||||
- learn which SimpleX Messaging Protocol servers are used as receive queues for which users
|
||||
- learn which SimpleX Messaging Protocol servers are used as receive queues for which users.
|
||||
|
||||
- learn when messages are sent and received
|
||||
- learn when messages are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose
|
||||
|
||||
@@ -217,43 +248,83 @@ SimpleX agents provide higher-level operations compared to SimpleX Clients, who
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when a queue recipient or sender is online
|
||||
- learn when a queue recipient is online
|
||||
|
||||
- know how many messages are sent via the queue (although some may be noise)
|
||||
- know how many messages are sent via the queue (although some may be noise or not content messages).
|
||||
|
||||
- perform queue correlation (matching multiple queues to a single user) via either a re-used transport connection, user's IP Address, or connection timing regularities
|
||||
- learn which messages would trigger notifications even if a user does not use [push notifications](./push-notifications.md).
|
||||
|
||||
- learn a user's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
- perform the correlation of the queue used to receive messages (matching multiple queues to a single user) via either a re-used transport connection, user's IP Address, or connection timing regularities.
|
||||
|
||||
- drop all future messages inserted into a queue, detectable only over other, redundant queues
|
||||
- learn a recipient's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- drop all future messages inserted into a queue, detectable only over other, redundant queues.
|
||||
|
||||
- lie about the state of a queue to the recipient and/or to the sender (e.g. suspended or deleted when it is not).
|
||||
|
||||
- spam a user with invalid messages
|
||||
- spam a user with invalid messages.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents of messages
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the user's end-to-end encryption with an active attack
|
||||
- compromise the users' end-to-end encryption with an active attack.
|
||||
|
||||
- learn a sender's IP address, track them through other IP addresses they use to access the same queue, and infer information (e.g. employer) based on the IP addresses, even if Tor is not used (provided messages are sent via proxy SMP server).
|
||||
|
||||
- perform senders' queue correlation (matching multiple queues to a single sender) via either a re-used transport connection, user's IP Address, or connection timing regularities, unless it has additional information from the proxy SMP server (provided messages are sent via proxy SMP server).
|
||||
|
||||
#### SimpleX Messaging Protocol server that proxies the messages to another SMP server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a sender's IP address, as long as Tor is not used.
|
||||
|
||||
- learn when a sender with a given IP address is online.
|
||||
|
||||
- know how many messages are sent from a given IP address and to a given destination SMP server.
|
||||
|
||||
- drop all messages from a given IP address or to a given destination server.
|
||||
|
||||
- unless destination SMP server detects repeated public DH keys of senders, replay messages to a destination server within a single session, causing either duplicate message delivery (which will be detected and ignored by the receiving clients), or, when receiving client is not connected to SMP server, exhausting capacity of destination queues used within the session.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- perform queue correlation (matching multiple queues to a single user), unless it has additional information from the destination SMP server.
|
||||
|
||||
- undetectably add, duplicate, or corrupt individual messages.
|
||||
|
||||
- undetectably drop individual messages, so long as a subsequent message is delivered.
|
||||
|
||||
- learn the contents or type of messages.
|
||||
|
||||
- learn which messages would trigger notifications.
|
||||
|
||||
- learn the destination queues of messages.
|
||||
|
||||
- distinguish noise messages from regular messages except via timing regularities.
|
||||
|
||||
- compromise the user's end-to-end encryption with another user via an active attack.
|
||||
|
||||
- compromise the user's end-to-end encryption with the destination SMP servers via an active attack.
|
||||
|
||||
#### An attacker who obtained Alice's (decrypted) chat database
|
||||
|
||||
*can:*
|
||||
|
||||
- see the history of all messages exchanged by Alice with her communication partners
|
||||
- see the history of all messages exchanged by Alice with her communication partners.
|
||||
|
||||
- see shared profiles of contacts and groups
|
||||
- see shared profiles of contacts and groups.
|
||||
|
||||
- surreptitiously receive new messages sent to Alice via existing queues; until communication queues are rotated or the Double-Ratchet advances forward
|
||||
- surreptitiously receive new messages sent to Alice via existing queues; until communication queues are rotated or the Double-Ratchet advances forward.
|
||||
|
||||
- prevent Alice from receiving all new messages sent to her - either surreptitiously by emptying the queues regularly or overtly by deleting them
|
||||
- prevent Alice from receiving all new messages sent to her - either surreptitiously by emptying the queues regularly or overtly by deleting them.
|
||||
|
||||
- send messages from the user to their contacts; recipients will detect it as soon as the user sends the next message, because the previous message hash won’t match (and potentially won’t be able to decrypt them in case they don’t keep the previous ratchet keys).
|
||||
|
||||
@@ -269,41 +340,41 @@ SimpleX agents provide higher-level operations compared to SimpleX Clients, who
|
||||
|
||||
*can:*
|
||||
|
||||
- spam the user with messages
|
||||
- spam the user with messages.
|
||||
|
||||
- forever retain messages from the user
|
||||
- forever retain messages from the user.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- cryptographically prove to a third-party that a message came from a user (assuming the user’s device is not seized)
|
||||
- cryptographically prove to a third-party that a message came from a user (assuming the user’s device is not seized).
|
||||
|
||||
- prove that two contacts they have is the same user
|
||||
- prove that two contacts they have is the same user.
|
||||
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user.
|
||||
|
||||
#### An attacker who observes Alice showing an introduction message to Bob
|
||||
|
||||
*can:*
|
||||
|
||||
- Impersonate Bob to Alice
|
||||
- Impersonate Bob to Alice.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- Impersonate Alice to Bob
|
||||
- Impersonate Alice to Bob.
|
||||
|
||||
#### An attacker with Internet access
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service SimpleX messaging servers
|
||||
- Denial of Service SimpleX messaging servers.
|
||||
|
||||
- spam a user's public “contact queue” with connection requests
|
||||
- spam a user's public “contact queue” with connection requests.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send messages to a user who they are not connected with
|
||||
- send messages to a user who they are not connected with.
|
||||
|
||||
- enumerate queues on a SimpleX server
|
||||
- enumerate queues on a SimpleX server.
|
||||
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
Version 1, 2024-06-22
|
||||
|
||||
# Post-quantum resistant augmented double ratchet algorithm (PQDR)
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Comparison with the other approaches](#comparison-with-the-other-approaches)
|
||||
- [PQXDH for post-quantum key agreement](#pqxdh-for-post-quantum-key-agreement) (Signal)
|
||||
- [Hybrid Signal protocol for post-quantum encryption](#hybrid-signal-protocol-for-post-quantum-encryption) (Tutanota)
|
||||
- [Augmented double ratchet algorithm](#augmented-double-ratchet-algorithm)
|
||||
- [Double ratchet with encrypted headers augmented with double PQ KEM](#double-ratchet-with-encrypted-headers-augmented-with-double-pq-kem)
|
||||
- [Initialization](#initialization)
|
||||
- [Encrypting messages](#encrypting-messages)
|
||||
- [Decrypting messages](#decrypting-messages)
|
||||
- [Implementation considerations](#implementation-considerations)
|
||||
- [Chosen KEM algorithm](#chosen-kem-algorithm)
|
||||
- [Summary](#summary)
|
||||
|
||||
## Overview
|
||||
|
||||
It is a reasonable assumption that "record-now-decrypt-later" attacks are ongoing, so the users want to use cryptographic schemes for end-to-end encryption that are augmented with some post-quantum algorithm that is believed to be resistant to quantum computers.
|
||||
|
||||
SimpleX Chat uses [double-ratchet with header encryption](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption) to provide end-to-end encryption to messages and files. This document describes augmented algorithm with post-quantum key encapsulation mechanism (KEM) making it resistant to quantum computers.
|
||||
|
||||
Double-ratchet algorithm is a state of the art solution for end to end encryption offering a set of qualities that is not present in any other algorithm:
|
||||
|
||||
- perfect forward secrecy, i.e. compromise of session or long term keys does not lead to the ability to decrypt any of the past messages.
|
||||
- deniability (also known as repudiation), i.e. the fact that the recipient of the message while having the proof of message authenticity, cannot prove to a third party that the sender actually sent this message.
|
||||
- break-in recovery (also know as post-compromise security or future secrecy), i.e. the ability of the end-to-end encryption security to recover from the compromise of the long term keys. This is achieved by generating a new random key pair whenever a new DH key is received (DH ratchet step).
|
||||
|
||||
It is desirable to preserve all these qualities when augmenting the algorithm with a post-quantum algorithm, and having these qualities resistant to both conventional and quantum computers.
|
||||
|
||||
## Comparison with the other approaches
|
||||
|
||||
### PQXDH for post-quantum key agreement
|
||||
|
||||
[The solution](https://signal.org/docs/specifications/pqxdh/) recently [introduced by Signal](https://signal.org/blog/pqxdh/) augments the initial key agreement ([X3DH](https://signal.org/docs/specifications/x3dh/)) that is made prior to double ratchet algorithm. This is believed to provide protection from "record-now-decrypt-later" attack, but if the attacker at any point obtains long term keys from any of the devices, the break-in recovery will not be post-quantum resistant, and the attacker with quantum computer will be able to decrypt all the subsequent messages.
|
||||
|
||||
### Hybrid Signal protocol for post-quantum encryption
|
||||
|
||||
[The solution](https://eprint.iacr.org/2021/875.pdf) [proposed by Tutanota](https://tutanota.com/blog/posts/pqmail-update/) aims to preserve the break-in recovery property of double ratchet, but in doing so it:
|
||||
- replaces rather than augments DH key agreement with post-quantum KEM mechanism, making it potentially vulnerable to conventional computers.
|
||||
- adds signature to the DH ratchet step, to compensate for not keeping DH key agreement, but losing the deniability property for some of the messages.
|
||||
|
||||
## Augmented double ratchet algorithm
|
||||
|
||||
The double ratchet algorithm is augmented with post-quantum KEM mechanism, preserving all properties of the double ratchet algorithm.
|
||||
|
||||
It is possible, because although double ratchet uses DH (which is a non-interactive key exchanges), it uses it "interactively", when the new DH keys are generated by both parties in turns. Parties of double-ratchet encrypted communication can run two post-quantum key encapsulation mechanisms in parallel with both DH and KEM key agreements in each DH ratchet step, making break-in recovery of double ratchet algorithm post-quantum resistant, without losing deniability or resistance to conventional computers.
|
||||
|
||||
Specifically, [double ratchet with encrypted headers](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption) is augmented with some post-quantum key encapsulation mechanism (KEM) as described below. A possible algorithm for PQ KEM is [NTRU-prime](https://ntruprime.cr.yp.to), that is currently adopted in SSH and has available implementations. It is important though that the proposed scheme can be used with any PQ KEM algorithm.
|
||||
|
||||
The downside of the scheme is its substantial size overhead, as the encapsulation key and encapsulated shared secret are added to the header of each message. For the algorithm described below NTRU-prime adds ~2-4kb to each message (depending on the key size and the chosen variant). See [this table](https://ntruprime.cr.yp.to/security.html) for key and ciphertext sizes and the assessment of the security level for various key sizes.
|
||||
|
||||
It is possible to reduce size overhead by using only one KEM agreement and making only one of two ratchet steps providing post-quantum resistant break-in recovery.
|
||||
|
||||
## Double ratchet with encrypted headers augmented with double PQ KEM
|
||||
|
||||
Algorithm below assumes that in addition to shared secret from the initial key agreement, there will be an encapsulation key available from the party that published its keys (Bob).
|
||||
|
||||
### Initialization
|
||||
|
||||
The double ratchet initialization is defined in pseudo-code. This pseudo-code is identical to Signal algorithm specification except for that parts that add post-quantum key agreement.
|
||||
|
||||
```
|
||||
// Alice obtained Bob's keys and initializes ratchet first
|
||||
def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, bob_pq_kem_encapsulation_key):
|
||||
state.DHRs = GENERATE_DH()
|
||||
state.DHRr = bob_dh_public_key
|
||||
// below added for post-quantum KEM
|
||||
state.PQRs = GENERATE_PQKEM()
|
||||
state.PQRr = bob_pq_kem_encapsulation_key
|
||||
state.PQRss = random // shared secret for KEM
|
||||
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret
|
||||
// above added for KEM
|
||||
// the next line augments DH key agreement with PQ shared secret
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
state.CKr = None
|
||||
state.Ns = 0
|
||||
state.Nr = 0
|
||||
state.PN = 0
|
||||
state.MKSKIPPED = {}
|
||||
state.HKs = shared_hka
|
||||
state.HKr = None
|
||||
state.NHKr = shared_nhkb
|
||||
|
||||
// Bob initializes ratchet second, having received Alice's connection request
|
||||
def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob_pq_kem_key_pair):
|
||||
state.DHRs = bob_dh_key_pair
|
||||
state.DHRr = None
|
||||
// below added for KEM
|
||||
state.PQRs = bob_pq_kem_key_pair
|
||||
state.PQRr = None
|
||||
state.PQRss = None
|
||||
state.PQRct = None
|
||||
// above added for KEM
|
||||
state.RK = SK
|
||||
state.CKs = None
|
||||
state.CKr = None
|
||||
state.Ns = 0
|
||||
state.Nr = 0
|
||||
state.PN = 0
|
||||
state.MKSKIPPED = {}
|
||||
state.HKs = None
|
||||
state.NHKs = shared_nhkb
|
||||
state.HKr = None
|
||||
state.NHKr = shared_hka
|
||||
```
|
||||
|
||||
`GENERATE_PQKEM` generates decapsulation/encapsulation key pair.
|
||||
|
||||
`PQKEM-ENC` is key encapsulation algorithm.
|
||||
|
||||
Other than commented lines, the above adds parameters `bob_pq_kem_encapsulation_key` and `bob_pq_kem_key_pair` to the ratchet initialization. Otherwise it is identical to the original double ratchet initialization.
|
||||
|
||||
### Encrypting messages
|
||||
|
||||
```
|
||||
def RatchetEncryptPQ2HE(state, plaintext, AD):
|
||||
state.CKs, mk = KDF_CK(state.CKs)
|
||||
// encapsulation key from PQRs and encapsulated shared secret is added to header
|
||||
header = HEADER_PQ2(
|
||||
dh = state.DHRs.public,
|
||||
kem = state.PQRs.public, // added for KEM #2
|
||||
ct = state.PQRct // added for KEM #1
|
||||
pn = state.PN,
|
||||
n = state.Ns,
|
||||
)
|
||||
enc_header = HENCRYPT(state.HKs, header)
|
||||
state.Ns += 1
|
||||
return enc_header, ENCRYPT(mk, plaintext, CONCAT(AD, enc_header))
|
||||
```
|
||||
|
||||
Other than adding encapsulation key and encapsulated shared secret into the header, the above is identical to the original double ratchet message encryption step.
|
||||
|
||||
### Decrypting messages
|
||||
|
||||
```
|
||||
def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD):
|
||||
plaintext = TrySkippedMessageKeysHE(state, enc_header, ciphertext, AD)
|
||||
if plaintext != None:
|
||||
return plaintext
|
||||
header, dh_ratchet = DecryptHeader(state, enc_header) // DecryptHeader is the same as in double ratchet specification
|
||||
if dh_ratchet:
|
||||
SkipMessageKeysHE(state, header.pn) // SkipMessageKeysHE is the same as in double ratchet specification
|
||||
DHRatchetPQ2HE(state, header)
|
||||
SkipMessageKeysHE(state, header.n)
|
||||
state.CKr, mk = KDF_CK(state.CKr)
|
||||
state.Nr += 1
|
||||
return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header))
|
||||
|
||||
// DecryptHeader is the same as in double ratchet specification
|
||||
def DecryptHeader(state, enc_header):
|
||||
header = HDECRYPT(state.HKr, enc_header)
|
||||
if header != None:
|
||||
return header, False
|
||||
header = HDECRYPT(state.NHKr, enc_header)
|
||||
if header != None:
|
||||
return header, True
|
||||
raise Error()
|
||||
|
||||
def DHRatchetPQ2HE(state, header):
|
||||
state.PN = state.Ns
|
||||
state.Ns = 0
|
||||
state.Nr = 0
|
||||
state.HKs = state.NHKs
|
||||
state.HKr = state.NHKr
|
||||
state.DHRr = header.dh
|
||||
// save new encapsulation key from header
|
||||
state.PQRr = header.kem
|
||||
// decapsulate shared secret from header - KEM #2
|
||||
ss = PQKEM-DEC(state.PQRs.private, header.ct)
|
||||
// use decapsulated shared secret with receiving ratchet
|
||||
state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
|
||||
state.DHRs = GENERATE_DH()
|
||||
// below is added for KEM
|
||||
state.PQRs = GENERATE_PQKEM() // generate new PQ key pair
|
||||
state.PQRss = random // shared secret for KEM
|
||||
state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1
|
||||
// above is added for KEM
|
||||
// use new shared secret with sending ratchet
|
||||
state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
```
|
||||
|
||||
`PQKEM-DEC` is key decapsulation algorithm.
|
||||
|
||||
`DHRatchetPQ2HE` augments both DH agreements with decapsulated shared secret from the received header and with the new shared secret, respectively. The new shared secret together with the new encapsulation key are saved in the state and will be added to the header in the next sent message.
|
||||
|
||||
Other than augmenting DH key agreements with the shared secrets from KEM, the above is identical to the original double ratchet DH ratchet step.
|
||||
|
||||
It is worth noting that while DH agreements work as ping-pong, when the new received DH key is used for both DH agreements (and only the sent DH key is updated for the second DH key agreement), PQ KEM agreements in the proposed scheme work as a "parallel ping-pong", with two balls in play all the time (two KEM agreements run in parallel).
|
||||
|
||||
## Implementation considerations for SimpleX Messaging Protocol
|
||||
|
||||
As SimpleX Messaging Protocol pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme can be compensated for by using ZSTD encryption of JSON bodies and image previews encoded as base64. While there may be some rare cases of random texts that would fail to compress, in all real scenarios it would not cause the message size reduction.
|
||||
|
||||
Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the invitation link. As encapsulation key is large, it may be inconvenient to share it in the link in some contexts, e.g. when QR codes are used.
|
||||
|
||||
It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm.
|
||||
|
||||
Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can be as slow as 10-20 keys per second, so using this primitive in groups larger than 10-20 members would result in slow performance.
|
||||
|
||||
For backward compatibility the implementation must support adding PQ-resistant key agreement to the existing connections.
|
||||
|
||||
It is also beneficial to support removing PQ-resistant key agreement from the connections that have them, e.g. as the group size grows.
|
||||
|
||||
### Chosen KEM algorithm
|
||||
|
||||
The implementation uses Streamlined NTRU-Prime 761 (sntrup761) that was also used for OpenSSH for a long time.
|
||||
|
||||
It was chosen over ML-KEM (Kyber) standardized by NIST for several reasons:
|
||||
|
||||
- sntrup761 was used in OpenSSH for a long period of time.
|
||||
- ML-KEM standardization process raised [concerns](https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/WFRDl8DqYQ4) [amongst](https://blog.cr.yp.to/20231003-countcorrectly.html) the experts.
|
||||
- ML-KEM (if modified) is likely to have conflicts with the existing patents, unlike sntrup761.
|
||||
|
||||
It was chosen over non-interactive CTIDH due to its slower implementation, and lack of optimized code for aarch64 CPUs used in mobile devices.
|
||||
|
||||
## Summary
|
||||
|
||||
If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided chosen KEM is secure.
|
||||
@@ -0,0 +1,398 @@
|
||||
Version 2, 2024-06-22
|
||||
|
||||
# Overview of push notifications for SimpleX Messaging Servers
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Introduction](#introduction)
|
||||
- [Participating servers](#participating-servers)
|
||||
- [Register device token to receive push notifications](#register-device-token-to-receive-push-notifications)
|
||||
- [Subscribe to connection notifications](#subscribe-to-connection-notifications)
|
||||
- [SimpleX Notification Server protocol](#simplex-notification-server-protocol)
|
||||
- [Register new notification token](#register-new-notification-token)
|
||||
- [Verify notification token](#verify-notification-token)
|
||||
- [Check notification token status](#check-notification-token-status)
|
||||
- [Replace notification token](#replace-notification-token)
|
||||
- [Delete notification token](#delete-notification-token)
|
||||
- [Subscribe to periodic notifications](#subscribe-to-periodic-notifications)
|
||||
- [Create SMP message notification subscription](#create-smp-message-notification-subscription)
|
||||
- [Check notification subscription status](#check-notification-subscription-status)
|
||||
- [Delete notification subscription](#delete-notification-subscription)
|
||||
- [Error responses](#error-responses)
|
||||
- [Threat model](#threat-model)
|
||||
|
||||
## Introduction
|
||||
|
||||
SimpleX Messaging servers already operate as push servers and deliver the messages to subscribed clients as soon as they are sent to the servers.
|
||||
|
||||
The reason for push notifications is to support instant message notifications on iOS that does not allow background services.
|
||||
|
||||
## Participating servers
|
||||
|
||||
The diagram below shows which servers participate in message notification delivery.
|
||||
|
||||
While push provider (e.g., APN) can learn how many notifications are delivered to the user, it cannot access message content, even encrypted, or any message metadata - the notifications are e2e encrypted between SimpleX Notification Server and the user's device.
|
||||
|
||||
```
|
||||
User's iOS device Internet Servers
|
||||
--------------------- . ------------------------ . -----------------------------
|
||||
. .
|
||||
. . can be self-hosted now
|
||||
+--------------+ . . +----------------+
|
||||
| SimpleX Chat | -------------- TLS --------------- | SimpleX |
|
||||
| client |------> SimpleX Messaging Protocol (SMP) ------> | Messaging |
|
||||
+--------------+ ---------------------------------- | Server |
|
||||
^ | . . +----------------+
|
||||
| | . . . . . | . . .
|
||||
| | . . | V |
|
||||
| | . . |SMP| TLS
|
||||
| | . . | | | SimpleX
|
||||
| | . . . . . V . . . NTF Server
|
||||
| | . . +----------------------------------+
|
||||
| | . . | +---------------+ |
|
||||
| | -------------- TLS --------------- | | SimpleX | can be |
|
||||
| |-----------> Notification Server Protocol -----> | | Notifications | self-hosted |
|
||||
| ---------------------------------- | | Subscriber | in the future |
|
||||
| . . | +---------------+ |
|
||||
| . . | | |
|
||||
| . . | V |
|
||||
| . . | +---------------+ |
|
||||
| . . | | SimpleX | |
|
||||
| . . | | Push | |
|
||||
| . . | | Server | |
|
||||
| . . | +---------------+ |
|
||||
| . . +----------------------------------+
|
||||
| . . . . . | . . .
|
||||
| . . | V |
|
||||
| . . |SMP| TLS
|
||||
| . . | | |
|
||||
| . . . . . V . . .
|
||||
| -------------- TLS --------------- +-----------------+
|
||||
|----------------- Notification delivery <-------| Apple PN server |
|
||||
---------------------------------- +-----------------+
|
||||
. .
|
||||
```
|
||||
|
||||
## Register device token to receive push notifications
|
||||
|
||||
This diagram shows the process of registering a device to receive PUSH notifications via Apple Push Notification (APN) servers.
|
||||
|
||||

|
||||
|
||||
## Subscribe to connection notifications
|
||||
|
||||
This diagram shows the process of subscription to notifications, notification delivery and device token update.
|
||||
|
||||

|
||||
|
||||
## SimpleX Notification Server protocol
|
||||
|
||||
To manage notification subscriptions to SMP servers, SimpleX Notification Server provides an RPC protocol with a similar design to SimpleX Messaging Protocol server.
|
||||
|
||||
This protocol sends requests and responses in a fixed size blocks of 512 bytes over TLS, uses the same [syntax of protocol transmissions](./simplex-messaging.md#smp-transmission-and-transport-block-structure) as SMP protocol, and has the same transport [handshake syntax](./simplex-messaging.md#transport-handshake) (except the server certificate is not included in the handshake).
|
||||
|
||||
Protocol commands have this syntax:
|
||||
|
||||
```
|
||||
ntfServerTransmission =
|
||||
ntfServerCmd = newTokenCmd / verifyTokenCmd / checkTokenCmd /
|
||||
replaceTokenCmd / deleteTokenCmd / cronCmd /
|
||||
newSubCmd / checkSubCmd / deleteSubCmd
|
||||
```
|
||||
### Register new notification token
|
||||
|
||||
This command should be used after the client app obtains a token from push notifications provider to register the token with the server.
|
||||
|
||||
Having received this command the server will deliver a test notification via the push provider to validate that the client has this token.
|
||||
|
||||
The command syntax:
|
||||
|
||||
```abnf
|
||||
newTokenCmd = %s"TNEW" SP newToken
|
||||
newToken = %s"T" deviceToken authPubKey clientDhPubKey
|
||||
deviceToken = pushProvider tokenString
|
||||
pushProvider = apnsDev / apnsProd / apnsNull
|
||||
apnsDev = "AD" ; APNS token for development environment
|
||||
apnsProd = "AP" ; APNS token for production environment
|
||||
apnsNull = "AN" ; token that does not trigger any notification delivery - used for server testing
|
||||
tokenString = shortString
|
||||
authPubKey = length x509encoded ; Ed25519 key used to verify clients commands
|
||||
clientDhPubKey = length x509encoded ; X25519 key to agree e2e encryption between the server and client
|
||||
shortString = length *OCTET
|
||||
length = 1*1 OCTET
|
||||
```
|
||||
|
||||
The server response syntax:
|
||||
|
||||
```abnf
|
||||
tokenIdResp = %s"IDTKN" SP entityId serverDhPubKey
|
||||
entityId = shortString
|
||||
serverDhPubKey = length x509encoded ; X25519 key to agree e2e encryption between the server and client
|
||||
```
|
||||
|
||||
### Verify notification token
|
||||
|
||||
This command is used to verify the token after the device receives the test notification from the push provider.
|
||||
|
||||
The command syntax:
|
||||
|
||||
```abnf
|
||||
verifyTokenCmd = %s"TVFY" SP regCode
|
||||
regCode = shortString
|
||||
```
|
||||
|
||||
The response to this command is `okResp` or `errorResp`
|
||||
|
||||
```abnf
|
||||
okResp = %s"OK"
|
||||
```
|
||||
|
||||
### Check notification token status
|
||||
|
||||
This command is used to check the token status:
|
||||
|
||||
```abnf
|
||||
checkTokenCmd = %s"TCHK"
|
||||
```
|
||||
|
||||
The response to this command:
|
||||
|
||||
```abnf
|
||||
tokenStatusResp = %s"TKN" SP tokenStatus
|
||||
tokenStatus = %s"NEW" / %s"REGISTERED" / %s"INVALID" / %s"CONFIRMED" / %s"ACTIVE" / %s"EXPIRED"
|
||||
```
|
||||
|
||||
### Replace notification token
|
||||
|
||||
This command should be used when push provider issues a new notification token.
|
||||
|
||||
It happens when:
|
||||
- the app data is migrated to another device.
|
||||
- the app is re-installed on the same device.
|
||||
- can happen periodically, at push provider discretion.
|
||||
|
||||
This command allows to replace the token without re-registering and re-subscribing all notification subscriptions.
|
||||
|
||||
Using this command triggers the same verification flow as registering a new token.
|
||||
|
||||
The command syntax:
|
||||
|
||||
```abnf
|
||||
replaceTokenCmd = %s"TRPL" SP deviceToken
|
||||
```
|
||||
|
||||
The response to this command is `okResp` or `errorResp`.
|
||||
|
||||
### Delete notification token
|
||||
|
||||
The command syntax:
|
||||
|
||||
```abnf
|
||||
deleteTokenCmd = %s"TDEL"
|
||||
```
|
||||
|
||||
The response to this command is `okResp` or `errorResp`.
|
||||
|
||||
After this command all message notification subscriptions will be removed and no more notifications will be sent.
|
||||
|
||||
### Subscribe to periodic notifications
|
||||
|
||||
This command enables or disables periodic notifications sent to the client device irrespective of message notifications.
|
||||
|
||||
This is useful for two reasons:
|
||||
- it provides better privacy from notification server, as while the server learns the device token, it doesn't learn anything else about user communications.
|
||||
- it allows to receive messages when notifications were dropped by push provider, e.g. while the device was offline, or lost by notification server, e.g. while it was restarting.
|
||||
|
||||
The command syntax:
|
||||
|
||||
```abnf
|
||||
cronCmd = %s"TCRN" SP interval
|
||||
interval = 2*2 OCTET ; Word16, minutes
|
||||
```
|
||||
|
||||
The interval for periodic notifications is set in minutes, with the minimum of 20 minutes. The client should pass `0` to disable periodic notifications.
|
||||
|
||||
### Create SMP message notification subscription
|
||||
|
||||
This command makes notification server subscribe to message notifications from SMP server and to deliver them to push provider:
|
||||
|
||||
```abnf
|
||||
newSubCmd = %s"SNEW" newSub
|
||||
newSub = %s "S" tokenId smpServer notifierId notifierKey
|
||||
tokenId = shortString ; returned in response to `TNEW` command
|
||||
smpServer = smpServer = hosts port fingerprint
|
||||
hosts = length 1*host
|
||||
host = shortString
|
||||
port = shortString
|
||||
fingerprint = shortString
|
||||
notifierId = shortString ; returned by SMP server in response to `NKEY` SMP command
|
||||
notifierKey = length x509encoded ; private key used to authorize requests to subscribe to message notifications
|
||||
```
|
||||
|
||||
The response syntax:
|
||||
|
||||
```abnf
|
||||
subIdResp = %s"IDSUB" SP entityId
|
||||
```
|
||||
|
||||
### Check notification subscription status
|
||||
|
||||
This command syntax:
|
||||
|
||||
```abnf
|
||||
checkSubCmd = %s"SCHK"
|
||||
```
|
||||
|
||||
The response:
|
||||
|
||||
```abnf
|
||||
subStatusResp = %s"SUB" SP subStatus
|
||||
subStatus = %s"NEW" / %s"PENDING" / ; e.g., after SMP server disconnect/timeout while ntf server is retrying to connect
|
||||
%s"ACTIVE" / %s"INACTIVE" / %s"END" / ; if another server subscribed to notifications
|
||||
%s"AUTH" / subErrStatus
|
||||
subErrStatus = %s"ERR" SP shortString
|
||||
```
|
||||
|
||||
### Delete notification subscription
|
||||
|
||||
The command syntax:
|
||||
|
||||
```abnf
|
||||
deleteSubCmd = %s"SDEL"
|
||||
```
|
||||
|
||||
The response to this command is `okResp` or `errorResp`.
|
||||
|
||||
After this command no more message notifications will be sent from this queue.
|
||||
|
||||
### Error responses
|
||||
|
||||
All commands can return error response:
|
||||
|
||||
```abnf
|
||||
errorResp = %s"ERR" SP errorType
|
||||
```
|
||||
|
||||
Where `errorType` has the same syntax as in [SimpleX Messaging Protocol](./simplex-messaging.md#error-responses)
|
||||
|
||||
## Threat Model
|
||||
|
||||
This threat model compliments SimpleX Messaging Protocol [threat model](./overview-tjr.md#threat-model)
|
||||
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
*can:*
|
||||
|
||||
- identify that and a user is using SimpleX push notifications.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- determine which servers a user subscribed to the notifications from.
|
||||
|
||||
#### A passive adversary able to monitor a set of senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- perform more efficient traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
#### SimpleX Messaging Protocol server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn which messages trigger push notifications.
|
||||
|
||||
- learn IP address of SimpleX notification servers used by the user.
|
||||
|
||||
- drop message notifications.
|
||||
|
||||
- spam a user with invalid notifications.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- learn user device token for push notifications.
|
||||
|
||||
- learn which queues belong to the same users with any additional efficiency compared with not using push notifications.
|
||||
|
||||
#### SimpleX Notification Server subscribed to message notifications
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a user device token.
|
||||
|
||||
- learn how many messaging queues and servers a user receives messages from.
|
||||
|
||||
- learn how many message notifications are delivered to the user from each queue.
|
||||
|
||||
- undetectably drop notifications.
|
||||
|
||||
- spam a user with background notifications.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- learn queue addresses for receiving or sending messages.
|
||||
|
||||
- learn the contents or type of messages (not even encrypted).
|
||||
|
||||
- learn anything about messages sent without notification flag.
|
||||
|
||||
- spam a user with visible notifications (provided the client app can filter push notifications).
|
||||
|
||||
- add, duplicate, or corrupt individual messages that will be shown to the user.
|
||||
|
||||
#### SimpleX Notification Server subscribed ONLY to periodic notifications
|
||||
|
||||
*can:*
|
||||
|
||||
- learn a user device token.
|
||||
|
||||
- drop periodic notifications.
|
||||
|
||||
- spam a user with background notifications.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- learn how many messaging queues and servers a user receives messages from.
|
||||
|
||||
- learn how many message notifications are delivered to the user from each queue.
|
||||
|
||||
- learn queue addresses for receiving or sending messages.
|
||||
|
||||
- learn the contents or type of messages (not even encrypted).
|
||||
|
||||
- learn anything about messages sent without notification flag.
|
||||
|
||||
- spam a user with visible notifications (provided the client app can filter push notifications).
|
||||
|
||||
- add, duplicate, or corrupt individual messages that will be shown to the user.
|
||||
|
||||
#### A user’s contact
|
||||
|
||||
*cannot:*
|
||||
|
||||
- determine if a user uses push notifications or not.
|
||||
|
||||
#### Push notification provider (e.g., APN)
|
||||
|
||||
*can:*
|
||||
|
||||
- learn that a user uses SimpleX app.
|
||||
|
||||
- learn how many notifications are delivered to user's device.
|
||||
|
||||
- drop notifications (in fact, APN coalesces notifications delivered while user's device is offline, delivering only the last one).
|
||||
|
||||
*cannot:*
|
||||
|
||||
- learn which SimpleX Messaging Protocol servers are used by a user (notifications are e2e encrypted).
|
||||
|
||||
- learn which or how many messaging queues a user receives notifications from.
|
||||
|
||||
- learn the contents or type of messages (not even encrypted, notifications only contain encrypted metadata).
|
||||
|
||||
#### An attacker with Internet access
|
||||
|
||||
*cannot:*
|
||||
|
||||
- register notification token not present on attacker's device.
|
||||
|
||||
- enumerate tokens or subscriptions on a SimpleX Notification Server.
|
||||
@@ -0,0 +1,634 @@
|
||||
Version 2, 2024-06-22
|
||||
|
||||
# SimpleX File Transfer Protocol
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Abstract](#abstract)
|
||||
- [Introduction](#introduction)
|
||||
- [XFTP Model](#xftp-model)
|
||||
- [Persistence model](#persistence-model)
|
||||
- [XFTP procedure](#xftp-procedure)
|
||||
- [File description](#file-description)
|
||||
- [URIs syntax](#uris-syntax)
|
||||
- [XFTP server URI](#xftp-server-uri)
|
||||
- [File description URI](#file-description-URI)
|
||||
- [XFTP qualities and features](#xftp-qualities-and-features)
|
||||
- [Cryptographic algorithms](#cryptographic-algorithms)
|
||||
- [File chunk IDs](#file-chunk-ids)
|
||||
- [Server security requirements](#server-security-requirements)
|
||||
- [Transport protocol](#transport-protocol)
|
||||
- [TLS ALPN](#tls-alpn)
|
||||
- [Connection handshake](#connection-handshake)
|
||||
- [Requests and responses](#requests-and-responses)
|
||||
- [XFTP commands](#xftp-commands)
|
||||
- [Correlating responses with commands](#correlating-responses-with-commands)
|
||||
- [Command authentication](#command-authentication)
|
||||
- [Keep-alive command](#keep-alive-command)
|
||||
- [File sender commands](#file-sender-commands)
|
||||
- [Register new file chunk](#register-new-file-chunk)
|
||||
- [Add file chunk recipients](#add-file-chunk-recipients)
|
||||
- [Upload file chunk](#upload-file-chunk)
|
||||
- [Delete file chunk](#delete-file-chunk)
|
||||
- [File recipient commands](#file-recipient-commands)
|
||||
- [Download file chunk](#download-file-chunk)
|
||||
- [Acknowledge file chunk download](#acknowledge-file-chunk-download)
|
||||
- [Threat model](#threat-model)
|
||||
|
||||
## Abstract
|
||||
|
||||
SimpleX File Transfer Protocol is a client-server protocol for asynchronous unidirectional file transmission.
|
||||
|
||||
It's designed with the focus on communication security, integrity and meta-data privacy, under the assumption that any part of the message transmission network can be compromised.
|
||||
|
||||
It is designed as a application level protocol to solve the problem of secure and private file transmission, making [MITM attacks][1] very difficult at any part of the file transmission system, and preserving meta-data privacy of the sent files.
|
||||
|
||||
## Introduction
|
||||
|
||||
The objective of SimpleX File Transfer Protocol (XFTP) is to facilitate the secure and private unidirectional transfer of files from senders to recipients via persistent file chunks stored by the xftp server.
|
||||
|
||||
XFTP is implemented as an application level protocol on top of HTTP2 and TLS.
|
||||
|
||||
The protocol describes the set of commands that senders and recipients can send to XFTP servers to create, upload, download and delete file chunks of several pre-defined sizes. XFTP servers SHOULD support chunks of 4 sizes: 64KB, 256KB, 1MB and 4MB (1KB = 1024 bytes, 1MB = 1024KB).
|
||||
|
||||
The protocol is designed with the focus on meta-data privacy and security. While using TLS, the protocol does not rely on TLS security by using additional encryption to achieve that there are no identifiers or ciphertext in common in received and sent server traffic, frustrating traffic correlation even if TLS is compromised.
|
||||
|
||||
XFTP does not use any form of participants' identities. It relies on out-of-band passing of "file description" - a human-readable YAML document with the list of file chunk locations, hashes and necessary cryptographic keys.
|
||||
|
||||
## XFTP Model
|
||||
|
||||
The XFTP model has three communication participants: the recipient, the file server (XFTP server) that is chosen and, possibly, controlled by the sender, and the sender.
|
||||
|
||||
XFTP server allows uploading fixed size file chunks, with or without basic authentication. The same party that can be the sender of one file chunk can be the recipient of another, without exposing it to the server.
|
||||
|
||||
Each file chunk allows multiple recipients, each recipient can download the same chunk multiple times. It allows depending on the threat model use the same recipient credentials for multiple parties, thus reducing server ability to understand the number of intended recipients (but server can still track IP addresses to determine it), or use one unique set of credentials for each recipient, frustrating traffic correlation on the assumption of compromised TLS. In the latter case, senders can create a larger number of recipient credentials to hide the actual number of intended recipients from the servers (which is what SimpleX clients do).
|
||||
|
||||
```
|
||||
Sender Internet XFTP relays Internet Recipient
|
||||
---------------------------- | ----------------- | ------------------- | ------------ | ----------
|
||||
| | | |
|
||||
| | (can be self-hosted) | |
|
||||
| | +---------+ | |
|
||||
chunk 1 ----- HTTP2 over TLS ------ | XFTP | ---- HTTP2 / TLS ----- chunk 1
|
||||
|---> SimpleX File Transfer Protocol (XFTP) --> | Relay | ---> XFTP ------------->|
|
||||
| --------------------------- +---------+ ---------------------- |
|
||||
| | | | | |
|
||||
| | | | | v
|
||||
+----------+ | | +---------+ | | +-------------+
|
||||
| Sending | ch. 2 ------- HTTP2 / TLS ------- | XFTP | ---- HTTP2 / TLS ---- ch. 2 | Receiving |
|
||||
file ---> | XFTP | ------> XFTP ----> | Relay | ---> XFTP ------> | XFTP | ---> file
|
||||
| Client | --------------------------- +---------+ ---------------------- | Client |
|
||||
+----------+ | | | | +-------------+
|
||||
| | | | | ^
|
||||
| | | +---------+ | | |
|
||||
| ------- HTTP2 / TLS ------- | XFTP | ---- HTTP2 / TLS ---- |
|
||||
|-------------> XFTP ----> | Relay | ---> XFTP ------------->|
|
||||
chunk N --------------------------- +---------+ --------------------- chunk N
|
||||
| | (store file chunks) | |
|
||||
| | | |
|
||||
| | | |
|
||||
```
|
||||
|
||||
When sender client uploads a file chunk, it has to register it first with one sender ID and multiple recipient IDs, and one random unique key per ID to authenticate sender and recipients, and also provide its size and hash that will be validated when chunk is uploaded.
|
||||
|
||||
To send the actual file, the sender client MUST pad it and encrypt it with a random symmetric key and distribute chunks of fixed sized across multiple XFTP servers. Information about chunk locations, keys, hashes and required keys is passed to the recipients as "[file description](#file-description)" out-of-band.
|
||||
|
||||
Creating, uploading, downloading and deleting file chunks requires sending commands to the XFTP server - they are described in detail in [XFTP commands](#xftp-commands) section.
|
||||
|
||||
## Persistence model
|
||||
|
||||
Server stores file chunk records in memory, with optional adding to append-only log, to allow restoring them on server restart. File chunk bodies can be stored as files or as objects in any object store (e.g. S3).
|
||||
|
||||
## XFTP procedure
|
||||
|
||||
1. Sending the file.
|
||||
|
||||
To send the file, the sender will:
|
||||
|
||||
1) Prepare file
|
||||
- compute its SHA512 digest.
|
||||
- prepend header with the name and pad the file to match the whole number of chunks in size. It is RECOMMENDED to use 2 of 4 allowed chunk sizes, to balance upload size and metadata privacy.
|
||||
- encrypt it with a randomly chosen symmetric key and IV (e.g., using NaCL secret_box).
|
||||
- split into allowed size chunks.
|
||||
- generate per-recipient keys. It is recommended that the sending client generates more per-recipient keys than the actual number of recipients, rounding up to a power of 2, to conceal the actual number of intended recipients.
|
||||
|
||||
2) Upload file chunks
|
||||
- register each chunk record with randomly chosen one or more (for redundancy) XFTP server(s).
|
||||
- optionally request additional recipient IDs, if required number of recipient keys didn't fit into register request.
|
||||
- upload each chunk to chosen server(s).
|
||||
|
||||
3) Prepare file descriptions, one per recipient.
|
||||
|
||||
The sending client combines addresses of all chunks and other information into "file description", different for each file recipient, that will include:
|
||||
|
||||
- an encryption key used to encrypt/decrypt the full file (the same for all recipients).
|
||||
- file SHA512 digest to validate download.
|
||||
- list of chunk descriptions; information for each chunk:
|
||||
- private Ed25519 key to sign commands for file transfer server.
|
||||
- chunk address (server host and chunk ID).
|
||||
- chunk sha512 digest.
|
||||
|
||||
To reduce the size of file description, chunks are grouped by the server host.
|
||||
|
||||
4) Send file description(s) to the recipient(s) out-of-band, via pre-existing secure and authenticated channel. E.g., SimpleX clients send it as messages via SMP protocol, but it can be done via any other channel.
|
||||
|
||||

|
||||
|
||||
2. Receiving the file.
|
||||
|
||||
Having received the description, the recipient will:
|
||||
|
||||
1) Download all chunks.
|
||||
|
||||
The receiving client can fall back to secondary servers, if necessary:
|
||||
- if the server is not available.
|
||||
- if the chunk is not present on the server (ERR AUTH response).
|
||||
- if the hash of the downloaded file chunk does not match the description.
|
||||
|
||||
Optionally recipient can acknowledge file chunk reception to delete file ID from server for this recipient.
|
||||
|
||||
2) Combine the chunks into a file.
|
||||
|
||||
3) Decrypt the file using the key in file description.
|
||||
|
||||
4) Extract file name and unpad the file.
|
||||
|
||||
5) Validate file digest with the file description.
|
||||
|
||||

|
||||
|
||||
## File description
|
||||
|
||||
"File description" is a human-readable YAML document that is sent via secure and authenticated channel.
|
||||
|
||||
It includes these fields:
|
||||
- `party` - "sender" or "recipient". Sender's file description is required to delete the file.
|
||||
- `size` - padded file size equal to total size of all chunks, see `fileSize` syntax below.
|
||||
- `digest` - SHA512 hash of encrypted file, base64url encoded string.
|
||||
- `key` - symmetric encryption key to decrypt the file, base64url encoded string.
|
||||
- `nonce` - nonce to decrypt the file, base64url encoded string.
|
||||
- `chunkSize` - default chunk size, see `fileSize` syntax below.
|
||||
- `replicas` - the array of file chunk replicas descriptions.
|
||||
- `redirect` - optional property for redirect information indicating that the file is itself a description to another file, allowing to use file description as a short URI.
|
||||
|
||||
Each replica description is an object with 2 fields:
|
||||
|
||||
- `chunks` - and array of chunk replica descriptions stored on one server.
|
||||
- `server` - [server address](#xftp-server-uri) where the chunks can be downloaded from.
|
||||
|
||||
Each server replica description is a string with this syntax:
|
||||
|
||||
```abnf
|
||||
chunkReplica = chunkNo ":" replicaId ":" replicaKey [":" chunkDigest [":" chunkSize]]
|
||||
chunkNo = 1*DIGIT
|
||||
; a sequential 1-based chunk number in the original file.
|
||||
replicaId = base64url
|
||||
; server-assigned random chunk replica ID.
|
||||
replicaKey = base64url
|
||||
; sender-generated random key to receive (or to delete, in case of sender's file description) the chunk replica.
|
||||
chunkDigest = base64url
|
||||
; chunk digest that MUST be specified for the first replica of each chunk,
|
||||
; and SHOULD be omitted (or be the same) on the subsequent replicas
|
||||
chunkSize = fileSize
|
||||
fileSize = sizeInBytes / sizeInUnits
|
||||
; chunk size SHOULD only be specified on the first replica and only if it is different from default chunk size
|
||||
sizeInBytes = 1*DIGIT
|
||||
sizeInUnits = 1*DIGIT sizeUnit
|
||||
sizeUnit = %s"kb" / %s"mb" / %s"gb"
|
||||
base64url = <base64url encoded binary> ; RFC4648, section 5
|
||||
```
|
||||
|
||||
Optional redirect information has two fields:
|
||||
- `size` - the size of the original encrypted file to which file description downloaded via the current file description will lead to, see `fileSize` syntax below.
|
||||
- `digest` - SHA512 hash of the original file, base64url encoded string.
|
||||
|
||||
## URIs syntax
|
||||
|
||||
### XFTP server URI
|
||||
|
||||
The XFTP server address is a URI with the following syntax:
|
||||
|
||||
```abnf
|
||||
xftpServerURI = %s"xftp://" xftpServer
|
||||
xftpServer = serverIdentity [":" basicAuth] "@" srvHost [":" port]
|
||||
srvHost = <hostname> ; RFC1123, RFC5891
|
||||
port = 1*DIGIT
|
||||
serverIdentity = base64url
|
||||
basicAuth = base64url
|
||||
```
|
||||
|
||||
### File description URI
|
||||
|
||||
This file description URI can be generated by the client application to share a small file description as a QR code or as a link. Practically, to be able to scan a QR code it should be under 1000 characters, so only file descriptions with 1-2 chunks can be used in this case. This is supported with `redirect` property when file description leads to a file which in itself is a larger file description to another file - akin to URL shortener.
|
||||
|
||||
File description URI syntax:
|
||||
|
||||
```abnf
|
||||
fileDescriptionURI = serviceScheme "/file" "#/?desc=" description [ "&data=" userData ]
|
||||
serviceScheme = (%s"https://" clientAppServer) | %s"simplex:"
|
||||
clientAppServer = hostname [ ":" port ]
|
||||
; client app server, e.g. simplex.chat
|
||||
description = <URI-escaped YAML file description>
|
||||
userData = <any URI-compatible string>
|
||||
```
|
||||
|
||||
clientAppServer is not a server the client connects to - it is a server that shows the instruction on how to download the client app that will connect using this connection request. This server can also host a mobile or desktop app manifest so that this link is opened directly in the app if it is installed on the device.
|
||||
|
||||
"simplex" URI scheme in serviceScheme can be used instead of client app server. Client apps MUST support this URI scheme.
|
||||
|
||||
## XFTP qualities and features
|
||||
|
||||
XFTP stands for SimpleX File Transfer Protocol. Its design is based on the same ideas and has some of the qualities of SimpleX Messaging Protocol:
|
||||
|
||||
- recipient cannot see sender's IP address, as the file fragments (chunks) are temporarily stored on multiple XFTP relays.
|
||||
- file can be sent asynchronously, without requiring the sender to be online for file to be received.
|
||||
- there is no network of peers that can observe this transfer - sender chooses which XFTP relays to use, and can self-host their own.
|
||||
- XFTP relays do not have any file metadata - they only see individual chunks, with access to each chunk authorized with anonymous credentials (using Edwards curve cryptographic signature) that are random per chunk.
|
||||
- chunks have one of the sizes allowed by the servers - 64KB, 256KB, 1MB and 4MB chunks, so sending a large file looks indistinguishable from sending many small files to XFTP server. If the same transport connection is reused, server would only know that chunks are sent by the same user.
|
||||
- each chunk can be downloaded by multiple recipients, but each recipient uses their own key and chunk ID to authorize access, and the chunk is encrypted by a different key agreed via ephemeral DH keys (NaCl crypto_box (SalsaX20Poly1305 authenticated encryption scheme ) with shared secret derived from Curve25519 key exchange) on the way from the server to each recipient. XFTP protocol as a result has the same quality as SMP protocol - there are no identifiers and ciphertext in common between sent and received traffic inside TLS connection, so even if TLS is compromised, it complicates traffic correlation attacks.
|
||||
- XFTP protocol supports redundancy - each file chunk can be sent via multiple relays, and the recipient can choose the one that is available. Current implementation of XFTP protocol in SimpleX Chat does not support redundancy though.
|
||||
- the file as a whole is encrypted with a random symmetric key using NaCl secret_box.
|
||||
|
||||
## Cryptographic algorithms
|
||||
|
||||
Clients must cryptographically authorize XFTP commands, see [Command authentication](#command-authentication).
|
||||
|
||||
To authorize/verify transmissions clients and servers MUST use either signature algorithm Ed25519 algorithm defined in RFC8709 or using deniable authentication scheme based on NaCL crypto_box (see Simplex Messaging Protocol).
|
||||
|
||||
To encrypt/decrypt file chunk bodies delivered to the recipients, servers/clients MUST use NaCL crypto_box.
|
||||
|
||||
Clients MUST encrypt file chunk bodies sent via XFTP servers using use NaCL crypto_box.
|
||||
|
||||
## File chunk IDs
|
||||
|
||||
XFTP servers MUST generate a separate new set of IDs for each new chunk - for the sender (that uploads the chunk) and for each intended recipient. It is REQUIRED that:
|
||||
|
||||
- These IDs are different and unique within the server.
|
||||
- Based on random bytes generated with cryptographically strong pseudo-random number generator.
|
||||
|
||||
## Server security requirements
|
||||
|
||||
XFTP server implementations MUST NOT create, store or send to any other servers:
|
||||
|
||||
- Logs of the client commands and transport connections in the production environment.
|
||||
|
||||
- History of retrieved files.
|
||||
|
||||
- Snapshots of the database they use to store file chunks (instead clients can manage redundancy by creating chunk replicas using more than one XFTP server). In-memory persistence is recommended for file chunks records.
|
||||
|
||||
- Any other information that may compromise privacy or [forward secrecy][4] of communication between clients using XFTP servers.
|
||||
|
||||
## Transport protocol
|
||||
|
||||
- binary-encoded commands sent as fixed-size padded block in the body of HTTP2 POST request, similar to SMP and notifications server protocol transmission encodings.
|
||||
- HTTP2 POST with a fixed size padded block body for file upload and download.
|
||||
|
||||
Block size - 4096 bytes (it would fit ~120 Ed25519 recipient keys).
|
||||
|
||||
The reasons to use HTTP2:
|
||||
|
||||
- avoid the need to have two hostnames (or two different ports) for commands and file uploads.
|
||||
- compatibility with the existing HTTP2 client libraries.
|
||||
|
||||
The reason not to use JSON bodies:
|
||||
|
||||
- bigger request size, so fewer recipient keys would fit in a single request
|
||||
- signature over command has to be outside of JSON anyway.
|
||||
|
||||
The reason not to use URI segments / HTTP verbs / REST semantics is to have consistent request size.
|
||||
|
||||
### ALPN to agree handshake version
|
||||
|
||||
Client and server use [ALPN extension][18] of TLS to agree handshake version.
|
||||
|
||||
Server SHOULD send `xftp/1` protocol name and the client should confirm this name in order to use the current protocol version. This is added to allow support of older clients without breaking backward compatibility and to extend or modify handshake syntax.
|
||||
|
||||
If the client does not confirm this protocol name, the server would fall back to v1 of XFTP protocol.
|
||||
|
||||
### Transport handshake
|
||||
|
||||
When a client and a server agree on handshake version using ALPN extension, they should proceed with XFTP handshake.
|
||||
|
||||
As with SMP, a client doesn't reveal its version range to avoid version fingerprinting. Unlike SMP, XFTP runs a HTTP2 protocol over TLS and the server can't just send its handshake right away. So a session handshake is driven by client-sent requests:
|
||||
|
||||
1. To pass initiative to the server, the client sends a request with empty body.
|
||||
2. Server responds with its `paddedServerHello` block.
|
||||
3. Clients sends a request containing `paddedClientHello` block,
|
||||
4. Server sends an empty response, finalizing the handshake.
|
||||
|
||||
Once TLS handshake is complete, client and server will exchange blocks of fixed size (16384 bytes).
|
||||
|
||||
```abnf
|
||||
paddedServerHello = <padded(serverHello, 16384)>
|
||||
serverHello = xftpVersionRange sessionIdentifier serverCert signedServerKey ignoredPart
|
||||
xftpVersionRange = minXftpVersion maxXftpVersion
|
||||
minXftpVersion = xftpVersion
|
||||
maxXftpVersion = xftpVersion
|
||||
sessionIdentifier = shortString
|
||||
; unique session identifier derived from transport connection handshake
|
||||
serverCert = originalLength <x509encoded>
|
||||
signedServerKey = originalLength <x509encoded> ; signed by server certificate
|
||||
|
||||
paddedClientHello = <padded(clientHello, 16384)>
|
||||
clientHello = xftpVersion keyHash ignoredPart
|
||||
; chosen XFTP protocol version - must be the maximum supported version
|
||||
; within the range offered by the server
|
||||
|
||||
xftpVersion = 2*2OCTET ; Word16 version number
|
||||
keyHash = shortString
|
||||
shortString = length length*OCTET
|
||||
length = 1*1OCTET
|
||||
originalLength = 2*2OCTET
|
||||
ignoredPart = *OCTET
|
||||
```
|
||||
|
||||
In XFTP v2 the handshake is only used for version negotiation, but `serverCert` and `signedServerKey` must be validated by the client.
|
||||
|
||||
`keyHash` is the CA fingerprint used by client to validate TLS certificate chain and is checked by a server against its own key.
|
||||
|
||||
`ignoredPart` in handshake allows to add additional parameters in handshake without changing protocol version - the client and servers must ignore any extra bytes within the original block length.
|
||||
|
||||
For TLS transport client should assert that `sessionIdentifier` is equal to `tls-unique` channel binding defined in [RFC 5929][14] (TLS Finished message struct); we pass it in `serverHello` block to allow communication over some other transport protocol (possibly, with another channel binding).
|
||||
|
||||
### Requests and responses
|
||||
|
||||
- File sender:
|
||||
- create file chunk record.
|
||||
- Parameters:
|
||||
- Ed25519 key for subsequent sender commands and Ed25519 keys for commands of each recipient.
|
||||
- chunk size.
|
||||
- Response:
|
||||
- chunk ID for the sender and different IDs for all recipients.
|
||||
- add recipients to file chunk
|
||||
- Parameters:
|
||||
- sender's chunk ID
|
||||
- Ed25519 keys for commands of each recipient.
|
||||
- Response:
|
||||
- chunk IDs for new recipients.
|
||||
- upload file chunk.
|
||||
- delete file chunk (invalidates all recipient IDs).
|
||||
- File recipient:
|
||||
- download file chunk:
|
||||
- chunk ID
|
||||
- DH key for additional encryption of the chunk.
|
||||
- command should be signed with the key passed by the sender when creating chunk record.
|
||||
- delete file chunk ID (only for one recipient): signed with the same key.
|
||||
|
||||
## XFTP commands
|
||||
|
||||
Commands syntax below is provided using ABNF with case-sensitive strings extension.
|
||||
|
||||
```abnf
|
||||
xftpCommand = ping / senderCommand / recipientCmd / serverMsg
|
||||
senderCommand = register / add / put / delete
|
||||
recipientCmd = get / ack
|
||||
serverMsg = pong / sndIds / rcvIds / ok / file
|
||||
```
|
||||
|
||||
The syntax of specific commands and responses is defined below.
|
||||
|
||||
### Correlating responses with commands
|
||||
|
||||
Commands are made via HTTP2 requests, responses to commands are correlated as HTTP2 responses.
|
||||
|
||||
### Command authentication
|
||||
|
||||
XFTP servers must authenticate all transmissions (excluding `ping`) by verifying the client signatures. Command signature should be generated by applying the algorithm specified for the file to the `signed` block of the transmission, using the key associated with the file chunk ID (recipient's or sender's depending on which file chunk ID is used).
|
||||
|
||||
### Keep-alive command
|
||||
|
||||
To keep the transport connection alive and to generate noise traffic the clients should use `ping` command to which the server responds with `pong` response. This command should be sent unsigned and without file chunk ID.
|
||||
|
||||
```abnf
|
||||
ping = %s"PING"
|
||||
```
|
||||
|
||||
This command is always sent unsigned.
|
||||
|
||||
data FileResponse = ... | FRPong | ...
|
||||
|
||||
```abnf
|
||||
pong = %s"PONG"
|
||||
```
|
||||
|
||||
### File sender commands
|
||||
|
||||
Sending any of the commands in this section (other than `register`, that is sent without file chunk ID) is only allowed with sender's ID.
|
||||
|
||||
#### Register new file chunk
|
||||
|
||||
This command is sent by the sender to the XFTP server to register a new file chunk.
|
||||
|
||||
Servers SHOULD support basic auth with this command, to allow only server owners and trusted users to create file chunks on the servers.
|
||||
|
||||
The syntax is:
|
||||
|
||||
```abnf
|
||||
register = %s"FNEW " fileInfo rcvPublicAuthKeys basicAuth
|
||||
fileInfo = sndKey size digest
|
||||
sndKey = length x509encoded
|
||||
size = 1*DIGIT
|
||||
digest = length *OCTET
|
||||
rcvPublicAuthKeys = length 1*rcvPublicAuthKey
|
||||
rcvPublicAuthKey = length x509encoded
|
||||
basicAuth = "0" / "1" length *OCTET
|
||||
|
||||
x509encoded = <binary X509 key encoding>
|
||||
|
||||
length = 1*1 OCTET
|
||||
```
|
||||
|
||||
If the file chunk is registered successfully, the server must send `sndIds` response with the sender's and recipients' file chunk IDs:
|
||||
|
||||
```abnf
|
||||
sndIds = %s"SIDS " senderId recipientIds
|
||||
senderId = length *OCTET
|
||||
recipientIds = length 1*recipientId
|
||||
recipientId = length *OCTET
|
||||
```
|
||||
|
||||
#### Add file chunk recipients
|
||||
|
||||
This command is sent by the sender to the XFTP server to add additional recipient keys to the file chunk record, in case number of keys requested by client didn't fit into `register` command. The syntax is:
|
||||
|
||||
```abnf
|
||||
add = %s"FADD " rcvPublicAuthKeys
|
||||
rcvPublicAuthKeys = length 1*rcvPublicAuthKey
|
||||
rcvPublicAuthKey = length x509encoded
|
||||
```
|
||||
|
||||
If additional keys were added successfully, the server must send `rcvIds` response with the added recipients' file chunk IDs:
|
||||
|
||||
```abnf
|
||||
rcvIds = %s"RIDS " recipientIds
|
||||
recipientIds = length 1*recipientId
|
||||
recipientId = length *OCTET
|
||||
```
|
||||
|
||||
#### Upload file chunk
|
||||
|
||||
This command is sent by the sender to the XFTP server to upload file chunk body to server. The syntax is:
|
||||
|
||||
```abnf
|
||||
put = %s"FPUT"
|
||||
```
|
||||
|
||||
Chunk body is streamed via HTTP2 request.
|
||||
|
||||
If file chunk body was successfully received, the server must send `ok` response.
|
||||
|
||||
```abnf
|
||||
ok = %s"OK"
|
||||
```
|
||||
|
||||
#### Delete file chunk
|
||||
|
||||
This command is sent by the sender to the XFTP server to delete file chunk from the server. The syntax is:
|
||||
|
||||
```abnf
|
||||
delete = %s"FDEL"
|
||||
```
|
||||
|
||||
Server should delete file chunk record, invalidating all recipient IDs, and delete file body from file storage. If file chunk was successfully deleted, the server must send `ok` response.
|
||||
|
||||
### File recipient commands
|
||||
|
||||
Sending any of the commands in this section is only allowed with recipient's ID.
|
||||
|
||||
#### Download file chunk
|
||||
|
||||
This command is sent by the recipient to the XFTP server to download file chunk body from the server. The syntax is:
|
||||
|
||||
```abnf
|
||||
get = %s"FGET " rDhKey
|
||||
rDhKey = length x509encoded
|
||||
```
|
||||
|
||||
If requested file is successfully located, the server must send `file` response. File chunk body is sent as HTTP2 response body.
|
||||
|
||||
```abnf
|
||||
file = %s"FILE " sDhKey cbNonce
|
||||
sDhKey = length x509encoded
|
||||
cbNonce = <nonce used in NaCl crypto_box encryption scheme>
|
||||
```
|
||||
|
||||
Chunk is additionally encrypted on the way from the server to the recipient using a key agreed via ephemeral DH keys `rDhKey` and `sDhKey`, so there is no ciphertext in common between sent and received traffic inside TLS connection, in order to complicate traffic correlation attacks, if TLS is compromised.
|
||||
|
||||
#### Acknowledge file chunk download
|
||||
|
||||
This command is sent by the recipient to the XFTP server to acknowledge file reception, deleting file ID from server for this recipient. The syntax is:
|
||||
|
||||
```abnf
|
||||
ack = %s"FACK"
|
||||
```
|
||||
|
||||
If file recipient ID is successfully deleted, the server must send `ok` response.
|
||||
|
||||
In current implementation of XFTP protocol in SimpleX Chat clients don't use FACK command. Files are automatically expired on servers after configured time interval.
|
||||
|
||||
## Threat model
|
||||
|
||||
#### Global Assumptions
|
||||
|
||||
- A user protects their local database and key material.
|
||||
- The user's application is authentic, and no local malware is running.
|
||||
- The cryptographic primitives in use are not broken.
|
||||
- A user's choice of servers is not directly tied to their identity or otherwise represents distinguishing information about the user.
|
||||
|
||||
#### A passive adversary able to monitor the traffic of one user
|
||||
|
||||
*can:*
|
||||
|
||||
- identify that and when a user is sending files over XFTP protocol.
|
||||
|
||||
- determine which servers the user sends/receives files to/from.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- see who sends files to the user and who the user sends the files to.
|
||||
|
||||
#### A passive adversary able to monitor a set of file senders and recipients
|
||||
|
||||
*can:*
|
||||
|
||||
- learn which XFTP servers are used to send and receive files for which users.
|
||||
|
||||
- learn when files are sent and received.
|
||||
|
||||
- perform traffic correlation attacks against senders and recipients and correlate senders and recipients within the monitored set, frustrated by the number of users on the servers.
|
||||
|
||||
- observe how much traffic is being sent, and make guesses as to its purpose.
|
||||
|
||||
- in case of a compromised transport protocol, correlate file senders and receivers.
|
||||
|
||||
*cannot, in case of a non-compromised transport protocol:*
|
||||
|
||||
- perform traffic correlation attacks.
|
||||
|
||||
#### XFTP server
|
||||
|
||||
*can:*
|
||||
|
||||
- learn when file senders and recipients are online.
|
||||
|
||||
- know how many file chunks and chunk sizes are sent via the server.
|
||||
|
||||
- perform the correlation of the file chunks as belonging to one file via either a re-used transport connection, user's IP address, or connection timing regularities.
|
||||
|
||||
- learn file senders' and recipients' IP addresses, and infer information (e.g. employer) based on the IP addresses, as long as Tor is not used.
|
||||
|
||||
- delete file chunks, preventing file delivery, as long as redundant delivery is not used.
|
||||
|
||||
- lie about the state of a file chunk to the recipient and/or to the sender (e.g. deleted when it is not).
|
||||
|
||||
- refuse deleting the file when instructed by the sender.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- undetectably corrupt file chunks.
|
||||
|
||||
- learn the contents, name or the exact size of sent files.
|
||||
|
||||
- learn approximate size of sent files, as long as more than one server is used to send file chunks.
|
||||
|
||||
- compromise the users' end-to-end encryption of files with an active attack.
|
||||
|
||||
#### An attacker who obtained Alice's (decrypted) chat database
|
||||
|
||||
*can:*
|
||||
|
||||
- see the history of all files exchanged by Alice with her communication partners, as long as files were not deleted from the database.
|
||||
|
||||
- receive all files sent and received by Alice that did not expire yet, as long as information about these files was not removed from the database.
|
||||
|
||||
- prevent Alice's contacts from receiving the files she sent by deleting all or some of the file chunks from XFTP servers.
|
||||
|
||||
#### A user's contact
|
||||
|
||||
*can:*
|
||||
|
||||
- spam the user with files.
|
||||
|
||||
- forever retain files from the user.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- cryptographically prove to a third-party that a file came from a user (assuming the user's device is not seized).
|
||||
|
||||
- prove that two contacts they have is the same user.
|
||||
|
||||
- cannot collaborate with another of the user's contacts to confirm they are communicating with the same user, even if they receive the same file.
|
||||
|
||||
#### An attacker with Internet access
|
||||
|
||||
*can:*
|
||||
|
||||
- Denial of Service XFTP servers.
|
||||
|
||||
*cannot:*
|
||||
|
||||
- send files to a user who they are not connected with.
|
||||
|
||||
- enumerate file chunks on an XFTP server.
|
||||
@@ -0,0 +1,350 @@
|
||||
Version 1, 2024-06-22
|
||||
|
||||
# SimpleX Remote Control Protocol
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Abstract](#abstract)
|
||||
- [XRCP model](#xrcp-model)
|
||||
- [Transport protocol](#transport-protocol)
|
||||
- [Session invitation](#session-invitation)
|
||||
- [Establishing TLS connection](#establishing-tls-connection)
|
||||
- [Session verification and protocol negotiation](#session-verification-and-protocol-negotiation)
|
||||
- [Controller/host session operation](#сontrollerhost-session-operation)
|
||||
- [Key agreement for announcement packet and for session](#key-agreement-for-announcement-packet-and-for-session)
|
||||
- [Threat model](#threat-model)
|
||||
|
||||
## Abstract
|
||||
|
||||
The SimpleX Remote Control Protocol is a client-server protocol designed to transform application UIs into thin clients, enabling remote control from another device. This approach allows users to remotely access and utilize chat profiles without the complexities of master-master replication for end-to-end encryption states.
|
||||
|
||||
Like SMP and XFTP, XRCP leverages out-of-band invitations to mitigate MITM attacks and employs multiple cryptographic layers to safeguard application data.
|
||||
|
||||
## XRCP model
|
||||
|
||||
XRCP assumes two application roles: host (that contain the application data) and controller that gains limited access to host data.
|
||||
Applications are also split into two components: UI and core.
|
||||
|
||||
When an XRCP session is established a host UI is locked out and a controller UI uses its core to proxy commands to the host core, getting back responses and events.
|
||||
|
||||
```
|
||||
|
||||
+------+ +------+ xrcp +------+ +------+
|
||||
| Ctrl | commands | Ctrl | commands | Host | | Host |
|
||||
user ---> | UI | -----------> | Core | -----------> | Core | | UI |
|
||||
+------+ +------+ +------+ +------+
|
||||
^ responses | ^ xrcp responses | ^
|
||||
|<------------------| |<-----------------| | +-------------+
|
||||
| events | | | Application |-+
|
||||
|<------------------| |----> | protocol | |
|
||||
| servers | |
|
||||
+-------------+ |
|
||||
+--------------+
|
||||
```
|
||||
|
||||
## Transport protocol
|
||||
|
||||
Protocol consists of four phases:
|
||||
- controller session invitation
|
||||
- establishing session TLS connection
|
||||
- session verification and protocol negotiation
|
||||
- session operation
|
||||
|
||||

|
||||
|
||||
### Session invitation
|
||||
|
||||
The invitation to the first session between host and controller pair MUST be shared out-of-band, to establish a long term identity keys/certificates of the controller to host device.
|
||||
|
||||
The subsequent sessions can be announced via an application-defined site-local multicast group, e.g. `224.0.0.251` (also used in mDNS/bonjour) and an application-defined port (SimpleX Chat uses 5227).
|
||||
|
||||
The session invitation contains this data:
|
||||
- supported version range for remote control protocol.
|
||||
- application-specific information, e.g. device name, application name and supported version range, settings, etc.
|
||||
- session start time in seconds since epoch.
|
||||
- if multicast is used, counter of announce packets sent by controller.
|
||||
- network address (ipv4 address and port) of the controller.
|
||||
- CA TLS certificate fingerprint of the controller - this is part of long term identity of the controller established during the first session, and repeated in the subsequent session announcements.
|
||||
- Session Ed25519 public key used to verify the announcement and commands - this mitigates the compromise of the long term signature key, as the controller will have to sign each command with this key first.
|
||||
- Long-term Ed25519 public key used to verify the announcement and commands - this is part of the long term controller identity.
|
||||
- Session X25519 DH key to agree session encryption (both for multicast announcement and for commands and responses in TLS), as described in https://datatracker.ietf.org/doc/draft-josefsson-ntruprime-hybrid/. The new keys are used for each session, and if client key is already available (from the previous session), the computed shared secret will be used to encrypt the announcement multicast packet. The out-of-band invitation is unencrypted. DH public key and KEM encapsulation key are sent unencrypted. NaCL crypto_box is used for encryption.
|
||||
|
||||
Host application decrypts (except the first session) and validates the invitation:
|
||||
- Session signature is valid.
|
||||
- Timestamp is within some window from the current time.
|
||||
- Long-term key signature is valid.
|
||||
- Long-term CA and signature key are the same as in the first session.
|
||||
- Some version in the offered range is supported.
|
||||
|
||||
OOB session invitation is a URI with this syntax:
|
||||
|
||||
```abnf
|
||||
sessionAddressUri = "xrcp:/" encodedCAFingerprint "@" host ":" port "#/?" qsParams
|
||||
encodedCAFingerprint = base64url
|
||||
host = <ipv4 or ipv6 address> ; in textual form, RFC4001
|
||||
port = 1*DIGIT ; uint16
|
||||
qsParams = param *("&" param)
|
||||
param = versionRangeParam / appInfoParam / sessionTsParam /
|
||||
sessPubKeyParam / idPubKeyParam / dhPubKeyParam /
|
||||
sessSignatureParam / idSignatureParam
|
||||
versionRangeParam = "v=" (versionParam / (versionParam "-" versionParam))
|
||||
versionParam = 1*DIGIT
|
||||
appInfoParam = "app=" escapedJSON
|
||||
sessionTsParam = "ts=" 1*DIGIT
|
||||
sessPubKeyParam = "skey=" base64url
|
||||
idPubKeyParam = "idkey=" base64url
|
||||
dhPubKeyParam = "dh=" base64url
|
||||
sessSignatureParam = "ssig=" base64url ; signs the URI with this and idSignatureParam param removed
|
||||
idSignatureParam = "idsig=" base64url ; signs the URI with this param removed
|
||||
base64url = <base64url encoded binary> ; RFC4648, section 5
|
||||
```
|
||||
|
||||
Multicast session announcement is a binary encoded packet with this syntax:
|
||||
|
||||
```abnf
|
||||
sessionAddressPacket = dhPubKey nonce encrypted(unpaddedSize sessionAddress packetPad)
|
||||
dhPubKey = length x509encoded ; same as announced
|
||||
nonce = length *OCTET
|
||||
sessionAddress = largeLength sessionAddressUri ; as above
|
||||
length = 1*1 OCTET ; for binary data up to 255 bytes
|
||||
largeLength = 2*2 OCTET ; for binary data up to 65535 bytes
|
||||
packetPad = <pad packet size to 1450 bytes> ; possibly, we may need to move KEM agreement one step later,
|
||||
; with encapsulation key in HELLO block and KEM ciphertext in reply to HELLO.
|
||||
```
|
||||
|
||||
### Establishing TLS connection
|
||||
|
||||
Both controller and host use 2-element certificate chains with unique self-signed CA root representing long-term identities. Leaf certificates aren't stored and instead generated on each session start.
|
||||
|
||||
A controller runs a TCP server to avoid opening listening socket on a host, which might create an attack vector. A controller keeps no sensitive data to be exposed this way.
|
||||
|
||||
During TLS handshake, parties validate certificate chains against previously known (from invitation or storage) CA fingerprints. The fingerprints MUST be the same as in the invitation and in the subsequent connections.
|
||||
|
||||
### Session verification and protocol negotiation
|
||||
|
||||
Once TLS session is established, both the host and controller devices present a "session security code" to the user who must match them (e.g., visually or via QR code scan) and confirm on the host device. The session security code must be a digest of tlsunique channel binding. As it is computed as a digest of the TLS handshake for both the controller and the host, it will validate that the same TLS certificates are used on both sides, and that the same TLS session is established, mitigating the possibility of MITM attack in the connection.
|
||||
|
||||
Once the session is confirmed by the user, the host sends HELLO block to the controller.
|
||||
|
||||
XRCP blocks inside TLS are padded to 16384 bytes.
|
||||
|
||||
Host HELLO block must contain:
|
||||
- new session DH key - used to compute new shared secret with the controller keys from the announcement.
|
||||
- encrypted part of HELLO block (JSON object), containing:
|
||||
- chosen protocol version.
|
||||
- host CA TLS certificate fingerprint - part of host long term identity - must match the one presented in TLS handshake and the previous sessions, otherwise the connection is terminated.
|
||||
- KEM encapsulation key - used to compute new shared secret for the session.
|
||||
- additional application specific parameters, e.g host device name, application version, host settings or JSON encoding format.
|
||||
|
||||
Host HELLO block syntax:
|
||||
|
||||
```abnf
|
||||
hostHello = %s"HELLO " dhPubKey nonce encrypted(unpaddedSize hostHelloJSON helloPad) pad
|
||||
unpaddedSize = largeLength
|
||||
dhPubKey = length x509encoded
|
||||
pad = <pad block size to 16384 bytes>
|
||||
helloPad = <pad hello size to 12888 bytes>
|
||||
largeLength = 2*2 OCTET
|
||||
```
|
||||
|
||||
The controller decrypts (including the first session) and validates the received HELLO block:
|
||||
- Chosen versions are supported (must be within offered ranges).
|
||||
- CA fingerprint matches the one presented in TLS handshake and the previous sessions - in subsequent sessions TLS connection should be rejected if the fingerprint is different.
|
||||
|
||||
[JTD schema](https://www.rfc-editor.org/rfc/rfc8927) for the encrypted part of host HELLO block `hostHelloJSON`:
|
||||
|
||||
```json
|
||||
{
|
||||
"definitions": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "[0-9]+"
|
||||
}
|
||||
},
|
||||
"base64url": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "base64url"
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"v": {"ref": "version"},
|
||||
"ca": {"ref": "base64url"},
|
||||
"kem": {"ref": "base64url"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"app": {"properties": {}, "additionalProperties": true}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
```
|
||||
|
||||
The controller should reply with with `ctrlHello` or `ctrlError` response:
|
||||
|
||||
```abnf
|
||||
ctrlHello = %s"HELLO " kemCiphertext encrypted(unpaddedSize ctrlHelloJSON helloPad) pad
|
||||
; ctrlHelloJSON is encrypted with the hybrid secret,
|
||||
; including both previously agreed DH secret and KEM secret from kemCiphertext
|
||||
unpaddedSize = largeLength
|
||||
kemCiphertext = largeLength *OCTET
|
||||
pad = <pad block size to 16384 bytes>
|
||||
helloPad = <pad hello size to 12888 bytes>
|
||||
largeLength = 2*2 OCTET
|
||||
|
||||
ctrlError = %s"ERROR " nonce encrypted(unpaddedSize ctrlErrorMessage helloPad) pad
|
||||
ctrlErrorMessage = <utf-8 encoded text>; encrypted using previously agreed DH secret.
|
||||
```
|
||||
|
||||
JTD schema for the encrypted part of controller HELLO block `ctrlHelloJSON`:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
}
|
||||
```
|
||||
|
||||
Controller `hello` block and all subsequent protocol messages are encrypted with the chain keys derived from the hybrid key (see key exchange below) - that is why conntroller hello block does not include nonce. That provides forward secrecy within the XRCP session. Receiving this `hello` block allows host to compute the same hybrid keys and to derive the same chain keys.
|
||||
|
||||
Once the controller replies HELLO to the valid host HELLO block, it should stop accepting new TCP connections.
|
||||
|
||||
### Controller/host session operation
|
||||
|
||||
The protocol for communication during the session is out of scope of this protocol.
|
||||
|
||||
SimpleX Chat uses HTTP2 encoding, where host device acts as a server and controller acts as a client (these roles are reversed compared with TLS connection, restoring client-server semantics in HTTP).
|
||||
|
||||
Payloads in the protocol must be encrypted using NaCL secret_box using the hybrid shared secret agreed during session establishment.
|
||||
|
||||
Commands of the controller must be signed after the encryption using the controller's session and long term Ed25519 keys.
|
||||
|
||||
tlsunique channel binding from TLS session MUST be included in commands (included in the signed body).
|
||||
|
||||
The syntax for encrypted command and response body encoding:
|
||||
|
||||
```abnf
|
||||
commandBody = counter encBody sessSignature idSignature [attachment]
|
||||
responseBody = counter encBody [attachment] ; counter must match command
|
||||
; counter is placed outside of encrypted body to allow correlating encryption keys
|
||||
; with the chain keys (each command and response are encrypted by different keys)
|
||||
encBody = encLength32 encrypted(tlsunique body)
|
||||
attachment = %x01 encLength32 encrypted(attachment)
|
||||
noAttachment = %x00
|
||||
tlsunique = length 1*OCTET
|
||||
counter = 8*8 OCTET ; int64
|
||||
encLength32 = 4*4 OCTET ; uint32, includes authTag
|
||||
```
|
||||
|
||||
If the command or response includes attachment, its hash must be included in command/response and validated.
|
||||
|
||||
## Key agreement for announcement packet and for session
|
||||
|
||||
Initial announcement is shared out-of-band (URI with xrcp scheme), and it is not encrypted.
|
||||
|
||||
This announcement contains only DH keys, as KEM key is too large to include in QR code, which are used to agree encryption key for host HELLO block. The host HELLO block will contain DH key in plaintext part and KEM encapsulation (public) key in encrypted part, that will be used to determine the shared secret (using SHA3-256 over concatenated DH shared secret and KEM encapsulated secret) to derive keys for controller HELLO response (that contains KEM ciphertext in plaintext part) and subsequent session commands and responses.
|
||||
|
||||
During the next session the announcement is sent via encrypted multicast block. The shared key for this announcement and for host HELLO block is determined using the KEM shared secret from the previous session and DH shared secret computed using the host DH key from the previous session and the new controller DH key from the announcement.
|
||||
|
||||
For the session, the shared secret is computed again using the KEM shared secret encapsulated by the controller using the new KEM key from the host HELLO block and DH shared secret computed using the host DH key from HELLO block and the new controller DH key from the announcement.
|
||||
|
||||
In pseudo-code:
|
||||
|
||||
```
|
||||
// session 1
|
||||
hostHelloSecret(1) = dhSecret(1)
|
||||
sessionSecret(1) = sha3-256(dhSecret(1) || kemSecret(1)) // to encrypt session 1 data, incl. controller hello
|
||||
dhSecret(1) = dh(hostHelloDhKey(1), controllerInvitationDhKey(1))
|
||||
kemCiphertext(1) = enc(kemSecret(1), kemEncKey(1))
|
||||
// kemEncKey is included in host HELLO, kemCiphertext - in controller HELLO
|
||||
kemSecret(1) = dec(kemCiphertext(1), kemDecKey(1))
|
||||
|
||||
// multicast announcement for session n
|
||||
announcementSecret(n) = sha256(dhSecret(n'))
|
||||
dhSecret(n') = dh(hostHelloDhKey(n - 1), controllerDhKey(n))
|
||||
|
||||
// session n
|
||||
hostHelloSecret(n) = dhSecret(n)
|
||||
sessionSecret(n) = sha3-256(dhSecret(n) || kemSecret(n)) // to encrypt session n data, incl. controller hello
|
||||
dhSecret(n) = dh(hostHelloDhKey(n), controllerDhKey(n))
|
||||
// controllerDhKey(n) is either from invitation or from multicast announcement
|
||||
kemCiphertext(n) = enc(kemSecret(n), kemEncKey(n))
|
||||
kemSecret(n) = dec(kemCiphertext(n), kemDecKey(n))
|
||||
```
|
||||
|
||||
If controller fails to store the new host DH key after receiving HELLO block, the encryption will become out of sync and the host won't be able to decrypt the next announcement. To mitigate it, the host should keep the last session DH key and also previous session DH key to try to decrypt the next announcement computing shared secret using both keys (first the new one, and in case it fails - the previous).
|
||||
|
||||
To decrypt a multicast announcement, the host should try to decrypt it using the keys of all known (paired) remote controllers.
|
||||
|
||||
Once kemSecret is agreed for the session, it is used to derive two chain keys, to receive and to send messages:
|
||||
|
||||
```
|
||||
host: sndKey, rcvKey = HKDF(kemSecret, "SimpleXSbChainInit", 64)
|
||||
controller: rcvKey, sndKey = HKDF(kemSecret, "SimpleXSbChainInit", 64)
|
||||
```
|
||||
|
||||
where HKDF is based on SHA512, with empty salt.
|
||||
|
||||
Actual keys and nonces to encrypt and decrypt messages are derived from these chain keys:
|
||||
|
||||
```
|
||||
to send: (sndKey', sk, nonce) = HKDF(sndKey, "SimpleXSbChain", 88)
|
||||
to receive: (rcvKey', sk, nonce) = HKDF(rcvKey, "SimpleXSbChain", 88)
|
||||
```
|
||||
|
||||
## Threat model
|
||||
|
||||
#### A passive network adversary able to monitor the site-local traffic:
|
||||
|
||||
*can:*
|
||||
- observe session times, duration and volume of the transmitted data between host and controller.
|
||||
|
||||
*cannot:*
|
||||
- observe the content of the transmitted data.
|
||||
- substitute the transmitted commands or responses.
|
||||
- replay transmitted commands or events from the hosts.
|
||||
|
||||
#### An active network adversary able to intercept and substitute the site-local traffic:
|
||||
|
||||
*can:*
|
||||
- prevent host and controller devices from establishing the session
|
||||
|
||||
*cannot:*
|
||||
- same as passive adversary, provided that user visually verified session code out-of-band.
|
||||
|
||||
#### An active adversary with the access to the network:
|
||||
|
||||
*can:*
|
||||
- spam controller device.
|
||||
|
||||
*cannot:*
|
||||
- compromise host or controller devices.
|
||||
|
||||
#### An active adversary with the access to the network who also observed OOB announcement:
|
||||
|
||||
*can:*
|
||||
- connect to controller instead of the host.
|
||||
- present incorrect data to the controller.
|
||||
|
||||
*cannot:*
|
||||
- connect to the host or make host connect to itself.
|
||||
|
||||
#### Compromised controller device:
|
||||
|
||||
*can:*
|
||||
- observe the content of the transmitted data.
|
||||
- access any data of the controlled host application, within the capabilities of the provided API.
|
||||
|
||||
*cannot:*
|
||||
- access other data on the host device.
|
||||
- compromise host device.
|
||||
|
||||
#### Compromised host device:
|
||||
|
||||
*can:*
|
||||
- present incorrect data to the controller.
|
||||
- incorrectly interpret controller commands.
|
||||
|
||||
*cannot:*
|
||||
- access controller data, even related to this host device.
|
||||
@@ -52,8 +52,8 @@ source_code: https://github.com/simplex-chat/simplexmq
|
||||
|
||||
# We should split this document to the model one, where specific parameters will be external to the document,
|
||||
# and specific to us, so that relay operators can adopt our recommended policy and publish any amendments separately.
|
||||
conditions: https://github.com/simplex-chat/simplex-chat/blob/_archived-ep/ios-file-provider/PRIVACY.md
|
||||
# conditions_amendments: link
|
||||
usage_conditions: https://github.com/simplex-chat/simplex-chat/blob/_archived-ep/ios-file-provider/PRIVACY.md
|
||||
# condition_amendments: link
|
||||
|
||||
server_country: SE
|
||||
operator: SimpleX Chat Ltd.
|
||||
@@ -62,9 +62,9 @@ website: https://simplex.chat
|
||||
admin_simplex: administrative SimpleX address
|
||||
admin_email: chat@simplex.chat
|
||||
admin_pgp: PGP key
|
||||
feedback_simplex: SimpleX address for feedback, comments and complaints
|
||||
feedback_email: complaints@simplex.chat
|
||||
feedback_pgp: PGP key
|
||||
complaints_simplex: SimpleX address for feedback, comments and complaints
|
||||
complaints_email: complaints@simplex.chat
|
||||
complaints_pgp: PGP key
|
||||
hosting: Linode / Akamai Inc.
|
||||
hosting_country: US
|
||||
```
|
||||
@@ -89,20 +89,27 @@ data ServerHandshake = ServerHandshake
|
||||
}
|
||||
|
||||
data ServerInformation = ServerInformation
|
||||
{ -- below is based on the existing server configuration
|
||||
persistence :: SMPServerPersistenceMode,
|
||||
{ config :: ServerPublicConfig,
|
||||
info :: ServerPublicInfo
|
||||
}
|
||||
|
||||
-- based on server configuration
|
||||
data ServerPublicConfig = ServerPublicConfig
|
||||
{ persistence :: SMPServerPersistenceMode,
|
||||
messageExpiration :: Int,
|
||||
statsEnabled :: Bool,
|
||||
newQueuesAllowed :: Bool,
|
||||
basicAuthEnabled :: Bool, -- server is private if enabled
|
||||
-- below is based on INFORMATION section of INI file
|
||||
sourceCode :: Text, -- note that this property is not optional, in line with AGPLv3 license
|
||||
-- all below properties are optional, except entity name MUST be present if any entity country is present
|
||||
basicAuthEnabled :: Bool -- server is private if enabled
|
||||
}
|
||||
|
||||
-- based on INFORMATION section of INI file
|
||||
data ServerPublicInfo = ServerPublicInfo
|
||||
{ sourceCode :: Text, -- note that this property is not optional, in line with AGPLv3 license
|
||||
conditions :: Maybe ServerConditions,
|
||||
operator :: Maybe Entity,
|
||||
website :: Maybe Text,
|
||||
admin :: Maybe ServerContactAddress,
|
||||
feedback :: Maybe ServerContactAddress,
|
||||
adminContacts :: Maybe ServerContactAddress,
|
||||
complaintsContacts :: Maybe ServerContactAddress,
|
||||
hosting :: Maybe Entity,
|
||||
serverCountry :: Maybe Text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Evolving agent API
|
||||
|
||||
## Problem
|
||||
|
||||
Historically, agent API started as a TCP protocol with encoding. We do not use the actual protocol and maintaining the encoding complicates the evolution of the API.
|
||||
|
||||
Currently, I was trying to add ERRS event to combine multiple subscription errors into one to prevent overloading the UI with processing multiple subscription errors (e.g.):
|
||||
|
||||
```haskell
|
||||
ERRS :: (ConnId, AgentErrorType) -> ACommand Agent AEConn
|
||||
```
|
||||
|
||||
This constructor is not possible to encode/parse in a sensible way other than including lengths of errors.
|
||||
|
||||
## Proposal
|
||||
|
||||
Remove commands type and encodings for commands and events.
|
||||
|
||||
Only keep encodings for the commands that are saved to the database: NEW, JOIN, LET, ACK, SWCH, DEL (this one is no longer used but needs to be supported for backwards compatibility).
|
||||
@@ -0,0 +1,124 @@
|
||||
# Short invitation links
|
||||
|
||||
## Problem
|
||||
|
||||
Long links look scary and unsafe for many users. While this is a perceived problem, rather than a real one, it hurts adoption.
|
||||
|
||||
What is worse, long links do not fit in profile descriptions of other social networks where people might want to advertize their contact addresses.
|
||||
|
||||
The current link size limitation is also the reason for not including PQ KEM keys into invitation links and addresses, postponing the moment when PQ-resistant encryption kicks in - if we include PQ KEM key into the link, the QR code will not be scannable.
|
||||
|
||||
Additionally, if we store short links, they can also include chat preferences and public profile data.
|
||||
|
||||
## Solution
|
||||
|
||||
MITM-resistant link shortening.
|
||||
|
||||
Instead of generating the random address that would resolve into the link - doing so would create the possibility of MITM by the server hosting this link - we can use private key as the link ID that will be passed to the accepting party, and the hash of the public key as ID for the server - the accepting party would present this key itself as ID and it will also be used for server to client encryption (see Protocol below). HKDF will be used to derive symmetric key from private key and used in secret_box together with random nonce (to allow replacing data with the same key but with a different nonce - nonce will be sent to the server too). secret_box construction is authenticated encryption, so it would protect from MITM.
|
||||
|
||||
The proposed syntax:
|
||||
|
||||
```abnf
|
||||
shortConnectionRequest = connectionScheme "/" connReqType "#/" smpServer "/" linkHash
|
||||
connReqType = %s"invitation" / %s"contact"
|
||||
connectionScheme = (%s"https://" clientAppServer) / %s"simplex:"
|
||||
clientAppServer = hostname [ ":" port ]
|
||||
; client app server, e.g. simplex.chat
|
||||
smpServer = serverIdentity "@" srvHosts [":" port] ; no smp:// prefix, no escaping
|
||||
srvHosts = <hostname> ["," srvHosts] ; RFC1123, RFC5891
|
||||
linkHash = <base64url encoded SHA256 or SHA512 hash of the original link>
|
||||
```
|
||||
|
||||
If SMP server supports pages, its name can be used as clientAppServer, without repeating it after #, for a shorter link.
|
||||
|
||||
Example link:
|
||||
|
||||
```
|
||||
https://simplex.chat/contact/#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im/abcdefghij0123456789abcdefghij0123456789abc=
|
||||
```
|
||||
|
||||
This link has the length of ~136 characters (256 bits), which is shorter than the full contact address (~310 characters) and much shorter than invitation links (~528 characters) even without post-quantum keys added to them.
|
||||
|
||||
This size can be further reduced by
|
||||
- use server domain in the link.
|
||||
- do not include onion address, as the connection happens via proxy anyway, if it's untrusted server.
|
||||
- not pinning server TLS certificate - the downside here is that while the attack that compromises TLS will not be able to substitute the link (because it's hash will not match), it will be able to intercept and to block it.
|
||||
- using shorter hash, e.g. SHA128 - reducing the collision resistance.
|
||||
|
||||
If the server is known, the client could use it's hash and onion address, otherwise it could trust the proxy to use any existing session with the same hostname or to accept the risk of interception - given that there is no risk of substitution.
|
||||
|
||||
With the first two of these "improvements" the link could be ~122 characters:
|
||||
|
||||
```
|
||||
https://smp8.simplex.im/contact/#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU@/abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
If onion address is preserved the link will be ~184 characters (won't fit in Twitter 160 characters bio):
|
||||
|
||||
```
|
||||
https://smp8.simplex.im/contact/#0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU@beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion/abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
If we implement it, the request to resolve the link would be made via proxied SMP command (to avoid the direct connection between the client and the recipient's server).
|
||||
|
||||
Pros:
|
||||
- a bit shorter link.
|
||||
- possibility to include post-quantum keys into the full link keeping the same shortened link size.
|
||||
- possibility to include chat profile of contact or group, and preferences, for a much better connection experience, and to show this information when the link sent in the conversation (clients can resolve them automatically, without connecting - it can be resolved by the sending clients).
|
||||
- server will not have access to the link.
|
||||
|
||||
Cons:
|
||||
- protocol complexity.
|
||||
- observers can access the link content, so for 1-time invitation we should only include permissions and not profile.
|
||||
|
||||
Pros are a huge improvement of UX of connecting both within and from outside of the app (e.g., link can be resolved even before creating chat profile, as part of the onboarding).
|
||||
|
||||
## Protocol
|
||||
|
||||
To support short links, the SMP servers would provide a simple key-value store enabled by three additional commands: `WRT`, `CLR` and `READ`
|
||||
|
||||
`WRT` command is used to store and to update values in the store. The size of the value is limited by the same size as sent messages (or, possibly, smaller - as connection information size used in confirmation messages) - the clients would use this fixed size irrespective of the content. `WRT` command will be sent with the data blob ID in the transaction entityId field, public authorization key used to authorize `WRT` and `CLR` commands (subsequent WRT commands to the existing key must use the same key), and the data blob.
|
||||
|
||||
`CLR` command must use with the same entity ID and must be authorized by the same key.
|
||||
|
||||
`READ` command must use the ID which hash would be equal of the ID used to create the data blob, and this ID would also be used as public authorization
|
||||
|
||||
## Algorithm to store and to retrieve data blob.
|
||||
|
||||
**Store data blob**
|
||||
|
||||
- the data blob owner generates X25519 key pair: `(k, pk)`.
|
||||
- private key `pk` will be included in the short link shared with the other party (only base64url encoded key bytes, not X509 encoding).
|
||||
- `HKDF(pk)` will be used to encrypt the link data with secret_box before storing it on the server.
|
||||
- the hash of public key `sha256(k)` will be used as ID by the owner to store and to remove the data blob (`WRT` and `CLR` commands).
|
||||
|
||||
**Retrieve data blob**
|
||||
|
||||
- the sender uses the public key `k` derived from the private key `pk` included in the link as entity ID to retrieve data blob (the server will compute the ID used by the owner as `sha256(k)` and will be able to look it up). This provides the quality that the traffic of the parties has no shared IDs inside TLS. It also means that unlike message queue creation, the ID to retrieve the blob was never sent to the blob creator, and also is not known to the server in advance (the second part is only an observation, in itself it does not increase security, as server has access to an encrypted blob anyway).
|
||||
- note that the sender does not authorize the request to retrieve the blob, as it would not increase security unless a different key is used to authorize, and adding a key would increase link size.
|
||||
- server session keys with the sender will be `(sk, spk)`, where `sk` is public key shared with the sender during session handshake, and `spk` is the private key known only to the server.
|
||||
- this public key `k` will also be combined with server session key `spk` using `dh(k, spk)` to encrypt the response, so that there is no ciphertext in common in sent and received traffic for these blobs. Correlation ID will be used as a nonce for this encryption.
|
||||
- having received the blob, the client can now decrypt it using secret_box with `HKDF(pk)`.
|
||||
|
||||
Using the same key as ID for the request, and also to additionally encrypt the response allows to use a single key in the link, without increasing the link size.
|
||||
|
||||
## Threat model
|
||||
|
||||
**Compromised SMP server**
|
||||
|
||||
can:
|
||||
- delete link data.
|
||||
- hide link selectively from some requests.
|
||||
|
||||
cannot:
|
||||
- undetectably replace link data.
|
||||
- access unencrypted link data, whether it was or was not accessed by the accepting party.
|
||||
- observe IP addresses of the users accessing link data.
|
||||
|
||||
**Passive observer who observed short link**:
|
||||
|
||||
can:
|
||||
- access original unencrypted link data
|
||||
|
||||
cannot:
|
||||
- replace or delete the link data
|
||||
@@ -0,0 +1,18 @@
|
||||
# iOS notifications stability
|
||||
|
||||
## Problem
|
||||
|
||||
iOS notifications may fail to deliver for several reasons, but there are two important reasons that we could address:
|
||||
- when notification server is not subscribed to SMP server(s), the notifications can be dropped - it can happen because either notification server restarts or becuase SMP server restarted and some messages are received before notification server resubscribed. We lose approximately 3% of notifications because of this reason.
|
||||
- when user device is offline or has low power condition, Apple does not deliver notification, but puts them to storage. If while the notification is in storage a new one arrives it would overwrite the previous notification. If it was the message to the same message queue, the client will download messages anyway, up to a limit, but if the message was to another queue, it will not be delivered until the app is opened. Apple delivers about 88% of notifications that should be delivered (not accounting for uninstalled apps), the rest is replaced with the newer notifications.
|
||||
|
||||
## Solution
|
||||
|
||||
The first problem can be solved by preserving notifications for a limited time (say 1 hour) in case there is no subscription to notification from notification server. At the very least, they can be preserved in SMP server memory but can also be stored to a file on restart, similar to messages, and be delivered when notification server resubscribes. It is sufficient to store one notification per messaging queue.
|
||||
|
||||
The second problem is both more damaging and more complex to solve. The solution could be to always deliver several last notifications to different queues in one packet (Apple allows up to ~4-5kb notification size, and we are sending packets of fixed size 512 bytes, so we could fit up to 8-10 of them in each notification).
|
||||
|
||||
Every time a client receives such batch of notifications if can:
|
||||
- check if that notification was already received in the previous batch.
|
||||
- if it was received, it would be ignored, otherwise it would be processed.
|
||||
- process them one by one, started from the most recent one while the time allows.
|
||||
@@ -0,0 +1,179 @@
|
||||
# SMP server message storage
|
||||
|
||||
## Problem
|
||||
|
||||
Currently SMP servers store all queues in server memory. As the traffic grows, so does the number of undelivered messages. What is worse, Haskell is not avoiding heap fragmentation when messages are allocated and then de-allocated - undelivered messages use ByteString and GC cannot move them around, as they use pinned memory.
|
||||
|
||||
## Possible solutions
|
||||
|
||||
### Solution 1: solve only GC fragmentation problem
|
||||
|
||||
Move from ByteString to some other primitive to store messages in memory long term, e.g. ShortByteString, or manage allocation/de-allocation of stored messages manually in some other way.
|
||||
|
||||
Pros: the simplest solution that avoids substantial re-engineering of the server.
|
||||
|
||||
Cons:
|
||||
- not a long term solution, as memory growth still has limits.
|
||||
- may be ineffective, as it introduces additional copying of bytes.
|
||||
|
||||
### Solution 2: move message storage to hard drive
|
||||
|
||||
Use files or RocksDB to store messages.
|
||||
|
||||
Pros:
|
||||
- much lower memory usage.
|
||||
- no message loss in case of abnormal server termination (important until clients have delivery redundancy).
|
||||
- this is a long term solution, and at some point it might need to be done anyway.
|
||||
|
||||
Cons:
|
||||
- substantial re-engineering costs and risks.
|
||||
- metadata privacy. Currently we only save undelivered messages when server is restarted, with this approach all messages will be stored for some time. this argument is limited, as hosting providers of VMs can make memory snapshots too, on the other hand they are harder to analyze than files. On another hand, with this approach messages will be stored for a shorter time.
|
||||
|
||||
#### RocksDB and other key-value stores
|
||||
|
||||
The downside of any key-value stores is that they don't seem to have efficient primitives for sequential delivery. While sequential delivery can be modelled with linked lists, they would require 1 key insert (on send), 3 key updates (1 update to update queue data on send, 1 update of the last message to point to the next, 1 update on delivery or message expiration) and 1 key deletion (on delivery or message expiration) for each delivered message.
|
||||
|
||||
This might result in substantial write amplification and compacting costs.
|
||||
|
||||
In general, tree structures that are efficient for quick lookups and updates, given approximately fixed value size, are inefficient for modelling queues.
|
||||
|
||||
#### Files
|
||||
|
||||
The upside of files is that they are well suited for sequential delivery and don't result in the same churn, with careful design, as trees do.
|
||||
|
||||
The downside of filesystem is that it does not scale well with the large number of files in a folder, so queues will have to be spread across multiple folders, following tree-like structure.
|
||||
|
||||
I could not find an available library that efficiently models sequential delivery in highly concurrent environment.
|
||||
|
||||
A possible design could be the following.
|
||||
|
||||
##### Queue folder and files
|
||||
|
||||
Each message queue is stored in its own folder (see below on folder locations). Folder would contain these files:
|
||||
|
||||
- `messages.abcd.log` - the file that is used to read messages from, sequentially
|
||||
- `messages.efgh.log` - the optional file that is used to write messages, in case it is different from read file.
|
||||
- `queue.log` - append-only file where the last line represents the current queue state
|
||||
- `queue.timestamp.log` - previous states of queue.log file
|
||||
|
||||
Each line in "queue.log" file has this syntax
|
||||
|
||||
```abnf
|
||||
queueLogLine =
|
||||
%s"read_file=" base64
|
||||
%s"read_msg=" digits
|
||||
%s"read_byte=" digits
|
||||
%s"write_file=" base64
|
||||
%s"write_msg=" digits
|
||||
```
|
||||
|
||||
When queue is first requested by the server:
|
||||
|
||||
```c
|
||||
if queue folder exists:
|
||||
read queue state from last line of queue.log
|
||||
if queue.log contained more than one line: // compaction
|
||||
copy queue.log to queue.timestamp.log
|
||||
write one line queue state to queue.log
|
||||
else:
|
||||
create queue folder
|
||||
create messages.abcd.log (abcd is some random string)
|
||||
read_msg = 0
|
||||
read_byte = 0
|
||||
create queue.log with one line: "read_file=abcd read_msg=0 read_byte=0 write_files=abcd write_msg=0"
|
||||
open read_file in ReadMode and seek to read_byte position
|
||||
nextReadByte = read_byte
|
||||
nextReadMsg = read_msg
|
||||
open write_file in AppendMode
|
||||
```
|
||||
|
||||
When message is added to the queue (assumes that queue state is loaded to server memory, if not the previous section will be done first):
|
||||
|
||||
```c
|
||||
if write_msg > max_queue_messages:
|
||||
return quota error
|
||||
else if write_msg = max_queue_messages:
|
||||
add quota_exceeded message to write_file
|
||||
update queue state: write_msg += 1
|
||||
append updated queue state to queue.log
|
||||
else
|
||||
// It is required that `max_queue_messages < max_file_messages`,
|
||||
// so that we never need more than one additional write file.
|
||||
if write_msg >= max_file_messages: // queue file rotation
|
||||
create messages.efgh.log // efgh is some random string
|
||||
update queue state: write_file=efgh write_msg=0 // read file remains the same as it was
|
||||
append updated queue state to queue.log
|
||||
copy queue.log to queue.timestamp.log
|
||||
// `old` needs to be defined to limit the number and storage duration,
|
||||
// preserving not more than N files, and not more than M days files, "and then some"
|
||||
// (that is if the queue has high churn, we have file from M days before in any case, for any debugging).
|
||||
delete `old` `queue.timestamp.log` files
|
||||
write one line queue state to queue.log // compaction
|
||||
|
||||
add message to write_file
|
||||
update queue state: write_msg += 1
|
||||
append updated queue state to queue.log
|
||||
```
|
||||
|
||||
The above algorithm assumes `max_queue_messages < than max_file_messages`, so that we never need more than one write file.
|
||||
|
||||
When message is delivered, it is simply read from the read queue, queue state does not change yet:
|
||||
|
||||
```c
|
||||
if nextReadMsg > read_msg:
|
||||
deliver cached message, no need to read it again
|
||||
else
|
||||
read message from read_file handle
|
||||
nextReadMsg = read_msg + 1
|
||||
nextReadByte = current position in file
|
||||
```
|
||||
|
||||
When message delivery is acknowledged, the read queue needs to be advanced, and possibly switched to read from the current write_queue:
|
||||
|
||||
```c
|
||||
if nextReadByte == read_byte:
|
||||
return error // nothing was delivered
|
||||
else if nextReadByte = EOF:
|
||||
// end of file is reached, possibly some other condition,
|
||||
// but it should allow changing max_file_messages on server restart
|
||||
currReadFile = read_file
|
||||
read_file = write_file
|
||||
read_msg = 0
|
||||
read_byte = 0
|
||||
append updated queue state to queue.log
|
||||
delete currReadFile
|
||||
else
|
||||
read_msg += 1
|
||||
read_byte = nextReadByte
|
||||
// `seek` should not be necessary, as the handle is already at nextReadByte position here
|
||||
// seek to read_byte
|
||||
append updated queue state to queue.log
|
||||
```
|
||||
|
||||
The above algorithm delegates the problem of compaction and fragmentation management to file system, that is very optimized for such scenarios.
|
||||
|
||||
Also, read and write files will grow to almost a constant size, so the space they used may be re-used.
|
||||
|
||||
An important consideration is that writes to queue.log and message.log files and queue state modifications have to be sequential, without concurrency - it can be managed with the usual locks.
|
||||
|
||||
##### Queue folders structure
|
||||
|
||||
Most Linux systems use EXT4 filesystem where the file lookup time scales linearly to the number of files. While alternatives with logarithmic lookup time exist (XFS), they may be very complex to configure on the existing systems.
|
||||
|
||||
So storing all queue folders in one folder won't scale.
|
||||
|
||||
To solve this problem we could use recipient queue ID in base64url format not as a folder name, but as a folder path, splitting it to path fragments of some length. The number of fragments can be configurable and migration to a different fragment size can be supported as the number of queues on a given server grows.
|
||||
|
||||
Currently, queue ID is 24 bytes random number, thus allowing 2^192 possible queue IDs. If we assume that a server must hold 1b queues, it means that we have ~2^162 possible addresses for each existing queue. 24 bytes in base64 is 32 characters that can be split into say 8 fragments with 4 characters each, so that queue folder path for queue with ID `abcdefghijklmnopqrstuvwxyz012345` would be:
|
||||
|
||||
`/var/opt/simplex/messages/abcd/efgh/ijkl/mnop/qrst/uvwx/yz01/2345`
|
||||
|
||||
The maximum theoretic number of the folders on the 1st level is 64^4, or 2^24 ~ 16m - this is probably still a large number of subfolders for EXT4. Given that addresses are random, all the possible combinations in the first folder can be used with a large number of queues.
|
||||
|
||||
So we could use an unequal split of path, two letters each and the last being long:
|
||||
|
||||
`/var/opt/simplex/messages/ab/cd/ef/ghijklmnopqrstuvwxyz012345`
|
||||
|
||||
The first three levels in this case can have 4096 subfolders each, and it gives 68b possible subfolders (64^2^3), so the last level will be sparse in case of 1b queues on the server. So we could make it 4 levels with 2 letters to never think about it, accounting for a large variance of the random numbers distribution:
|
||||
|
||||
`/var/opt/simplex/messages/ab/cd/ef/gh/ijklmnopqrstuvwxyz012345`
|
||||
@@ -0,0 +1,81 @@
|
||||
# Storage considerations for SMP queues
|
||||
|
||||
See [Short invitation links](./2024-06-21-short-links.md).
|
||||
|
||||
## Problem
|
||||
|
||||
1) queue records are created permanently, until the clients delete them.
|
||||
|
||||
2) clients only delete queue records based on some user action, pending connections do not expire.
|
||||
|
||||
While part 2 should be improved in the client, indefinite storage of queue records becomes a much bigger issue if each of them would result in a permanent storage of 4-16kb blob in server memory, without server-side expiration for short invitation links.
|
||||
|
||||
## Possible solutions
|
||||
|
||||
1) Add some queue timestamp, e.g. queue creation date, to expire unsecured queues after say 3 weeks.
|
||||
|
||||
The problem with this approach is that contact addresses are also unsecured queues, and they should not be expired.
|
||||
|
||||
We could set really large expiration time, and require that clients "update" the unsecured queues they need at least every 1-2 years, but it would not solve the problem of storing a large number of blobs in the server memory for unused/abandoned 1-time invitations.
|
||||
|
||||
2) Do not store blobs in memory / append-only log, and instead use something like RocksDB. While it may be a correct long term solution, it may be not expedient enough at the current POC stage for this feature. Also, the lack of expiration is wrong in any case and would indefinitely grow server storage.
|
||||
|
||||
3) Add flag allowing the server to differentiate permanent queues used as contact addresses, also using different blob sizes for them. In this case, messaging queues will be expired if not secured after 3 weeks, and contact address queues would be expired if not "updated" by the owner within 2 years.
|
||||
|
||||
Probably all three solutions need to be used, to avoid creating a non-expiring blob storage in memory, as in case too many of such blobs are created it would not be possible to differentiate between real users and resource exhaustion attacks, and unlike with messages, they won't be expiring too.
|
||||
|
||||
Servers already can differentiate messaging queues and contact address queues, if they want to:
|
||||
- with the old 4-message handshake, the confirmation message on a normal queue was different, and also KEY command was eventually used.
|
||||
- with the fast 2-message handshake, while the confirmation message has the same syntax, and the differences are inside encrypted envelope, the client still uses SKEY command.
|
||||
- in both cases, the usual messaging queues are secured, and contact addresses are not, so this difference is visible in the storage as well (although it is not easy to differentiate between abandoned 1-time invitations and contact addresses).
|
||||
|
||||
Differentiating these queues can also allow different message retention times - e.g., the queues for contact addresses could have bigger size, but have lower message retention time.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
1. Add queue updated_at date into queue records. While it adds some metadata, it seems necessary to manage retention and quality of service. It will not include exact time, only date, and the time of creation will be replaced by the time of any update - queue secured, a message is sent, or queue owner subscribes to the queue. To avoid the need to update store log on every message this information can be appended to store log on server termination. Or given that only one update per day is needed it may be ok to make these updates as they happen (temporarily making the sequence and time of these events available in storage).
|
||||
|
||||
2. Add flag to indicate the queue usage - messaging queue or queue for contact address connection requests. This would result in different queue size and different retention policy for queue and its messages. We already have "sender can secure flag" which is, effectively, this flag - contact address queues are never secured. So this does not increase stored metadata in any way.
|
||||
|
||||
## Possible changes to short links
|
||||
|
||||
This is a design considerations and a concept, not a design yet.
|
||||
|
||||
Instead of implementing a generic blob storage that can be used as an attack vector, and adds additional failure point (another server storing blob that is necessary to connect to the queue on the current server), but instead adds an extended queue information blobs, most of which could be dropped without the loss of connectivity, so that the attack can be mitigated by deleting these blobs without users losing the ability to connect, as long as the queue and minimal extended information is retained.
|
||||
|
||||
So, to make the connection there need to be these elements:
|
||||
|
||||
- queue server and queue ID - mandatory part, that can be included in short link
|
||||
- SMP key - mandatory part for all queues. We are considering initializing ratchets earlier for contact addresses, and include ratchet keys and pre-keys into queue data as well, but it is out of scope here.
|
||||
- Ratchet keys - mandatory part for 1-time invitation that won't fit in short link.
|
||||
- PQ key - optional part that can be stored with addresses if ratchet keys are added and with 1-time invitations.
|
||||
- App blobs - chat preferences for 1-time invitation links and profile information for contact addresses.
|
||||
|
||||
So rather that storing one blob with a large address inside it, not associated with the queue, increasing probability of failure and reducing our ability to mitigate resource exhaustion, we could store extended blobs associated with the queues.
|
||||
|
||||
Also, we need the address shared with the sender (party accepting the connection) to be short. We could use a similar approach that was proposed for data blobs, using a single random seed per queues to derive multiple keys and IDs from it. For example:
|
||||
|
||||
1. The queue owner:
|
||||
- generates Ed25529 key pair `(sk, spk)` and X25519 key pair `(dhk, dhpk)` to use with the server, same as now sent in NEW command.
|
||||
- generates queue recipient ID (this ID can still be server-generated).
|
||||
- generates X25519 key pair `(k, pk)` to use with the accepting party.
|
||||
- derives from `k`:
|
||||
- sender ID.
|
||||
- symmetric key for authenticated encryption of blobs.
|
||||
- `k` will be used as short link.
|
||||
2. All other data from the invitation can be included in queue creation request and be associated with the queue as 1-3 blobs with different priority:
|
||||
- ratchet keys - it will have a small size, so only this blob cannot be removed, while other blobs can be removed in case of resource exhaustion.
|
||||
- PQ keys - optional blob.
|
||||
- conversation preferences and profile - can be removed depending on creation time, e.g. all new blobs can be removed.
|
||||
|
||||
The algorithm used to derive key and ID from `k` needs to be cryptographically secure, e.g. it could be some KDF or ChaCha DRG initialized with `k` as seed, TBC.
|
||||
|
||||
So, coupling blob storage with messaging queues has these pros/cons:
|
||||
|
||||
Cons:
|
||||
- no additional layer of privacy - the server used for connection is visible in the link, even after the blobs are removed from the server.
|
||||
|
||||
Pros:
|
||||
- no additional point of failure in the connection process - the same server will be used to retrieve necessary blobs as for connection.
|
||||
- queue blobs of messaging blobs will be automatically removed once the queue is secured or expired, without additional request from the recipient - reducing the storage and the time these blobs are available.
|
||||
- queue blobs for contact addresses will be structured and some of the large blobs can be removed in case of resource exhaustion attack (and recreated by the client if needed), with the only downside that PQ handshake will be postponed (which is the case now) and profile will not be available at a point of connection.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Blob extensions for SMP queues
|
||||
|
||||
Evolution of the design for short links, see [here](./2024-06-21-short-links.md) and [here](./2024-09-05-queue-storage.md).
|
||||
|
||||
## Problems
|
||||
|
||||
Allow storing extended information with SMP queues to improve UX and security of making connections:
|
||||
- short invitation links and contact addresses.
|
||||
- PQ encryption from the first message.
|
||||
- present user profile with chat preferences and welcome message when the public address link is scanned.
|
||||
|
||||
## Design
|
||||
|
||||
1. Queue creation/update date is already added to server persistence, allowing to expire queues and blobs, depending on their usage.
|
||||
2. Add "queue type" metadata to NEW command to indicate whether messaging queue is used as public address or as messaging queue (see previous docs on why it doesn't change threat model). While at the moment it would match sndSecure flag there may be future scenarios when they diverge. Initially only "invitation" and "contact" types will be supported.
|
||||
3. Prohibit sndSecure flag for "contact" queues, prohibit securing contact queues.
|
||||
4. Add "queue blobs" to NEW command:
|
||||
- blob0: ratchetKeys up to N0 bytes - priority 0, can't be removed by the server, only in "invitation"
|
||||
- blob1: PQ key up to N1 bytes - priority 1, can be removed by the server, only used in "invitation"
|
||||
- blob2: Application data up to N2 bytes - priority 2, can be removed by the server.
|
||||
5. Add linkId to NEW command
|
||||
6. linkId and blobs will be removed when queue is secured.
|
||||
7. Add recipient command to remove/upsert blob2 for contact queues.
|
||||
8. Add sender command to retrieve blobs.
|
||||
|
||||
## Protocol
|
||||
|
||||
### Creating a queue:
|
||||
|
||||
The queue owner:
|
||||
- generates Ed25529 key pair `(sk, spk)` and X25519 key pair `(dhk, dhpk)` to use with the server, same as now. `sk` and `dhk` will be sent in NEW command.
|
||||
- generates X25519 key pair `(k, pk)` to use with the accepting party to encrypt queue messages.
|
||||
- derives from `k` using HKDF:
|
||||
- symmetric key `bk` for authenticated encryption of blobs.
|
||||
- `linkId`, will be sent in NEW command.
|
||||
- `k` will be used as short link.
|
||||
- sends NEW command.
|
||||
|
||||
NEW command syntax:
|
||||
|
||||
```abnf
|
||||
create = %s"NEW " linkId queueType recipientAuthPublicKey recipientDhPublicKey
|
||||
basicAuth subscribe sndSecure [ "0" blob0 ] [ "1" blob1 ] [ "2" blob2 ]
|
||||
queueType = %s"I" / %s "C" ; new parameter
|
||||
linkId = length *OCTET ; new parameter,
|
||||
; can be empty in which case blobs won't be allowed
|
||||
blob0 = word16 *OCTET ; new parameter, encrypted ratchet keys,
|
||||
; including nonce and auth tag
|
||||
blob1 = word16 *OCTET ; new parameter, encrypted PQ key
|
||||
blob2 = word16 *OCTET ; new parameter, encrypted application data
|
||||
```
|
||||
|
||||
SET - command to update queue blobs (recipientId is used as entity ID):
|
||||
|
||||
```abnf
|
||||
set = %s"SET " linkId [ "2" blob2 ] ; passing empty blob removes it
|
||||
linkId ; updated (or the same) linkId, can be empty to remove blobs
|
||||
; allows to change the address without removing the queue / changing blobs
|
||||
; (e.g., to avoid losing the messages).
|
||||
```
|
||||
|
||||
### Sending messages to the queue
|
||||
|
||||
GET - command to get queue blobs (linkId is used as entity ID):
|
||||
|
||||
```abnf
|
||||
get = %s"GET"
|
||||
```
|
||||
|
||||
Response to GET:
|
||||
|
||||
```abnf
|
||||
blobs = %s"BLOB" senderId [ "0" blob0 ] [ "1" blob1 ] [ "2" blob2 ]
|
||||
```
|
||||
|
||||
As blobs are retrieved using a separate linkId, once blobs are removed it will be impossible to find senderId from short link - it is a threat model improvement. Once server storage is compacted, it will be impossible to find queue related to the link even with the access to server data (unless server preserves the data).
|
||||
|
||||
### Possible privacy improvement
|
||||
|
||||
We could only allow unauthorized GET and authorized SET commands for long-term "contact" queues, and return BLOB in response to SKEY (or require that GET is authorized) - so that only the person who secures the queue will get access to data blobs. This way it ensures that the parties transmitting the invitation links cannot retrieve their content without the sender noticing it.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Private rendezvous protocol
|
||||
|
||||
## Problem
|
||||
|
||||
Our current handshake protocol is open to this attack: whoever observes the link exchange, knows on which server connection is being made, and if the traffic on this server is observed, then it can confirm communication between parties. Further, even with the [last proposal](./2024-09-09-smp-blobs.md#possible-privacy-improvement), having real-time access to the server data allows to establish the exact messaging queue that is used to send messages.
|
||||
|
||||
## Solution
|
||||
|
||||
We could make the initial link exchange more private by making it harder for any observer to discover which server will be used for messaging by hiding this information from the server that hosts the initial link.
|
||||
|
||||
Preliminary, the protocol could be the following:
|
||||
|
||||
1. Connection initiator stores 224-256 bytes of encrypted connection link on a rendezvous server (link contains server host and linkId on another messaging server, not a rendezvous one).
|
||||
|
||||
2. Rendezvous server adds these links to buckets, up to 64 links per bucket. Bucket ID is the timestamp when the bucket was created + a sequential bucket number, in case more than one bucket is created per second.
|
||||
|
||||
3. The server responds to the link creator with a bucket ID where this link was added. That bucket ID is its timestamp + a number prevents server "fingerprinting" clients and using say one bucket for each client. If timestamp is different or a bucket number within this timestamp is too large, the client can refuse to use it, depending on the client settings.
|
||||
|
||||
4. The initiating party will pass to the accepting party the rendezvous server host, the hash of this bucket ID (bucket link) and the passphrase to derive the key from. The initiating party has an option to pass a link and passphrase via two channels - in which case the link will only contain the bucket ID.
|
||||
|
||||
5. The accepting party would then request the bucket via its ID hash (the server would store hashes to be able to look up - hash is used to prevent showing time in the link) and attempt to decrypt all contained links using the provided key.
|
||||
The accepting party then will continue the connection via the decrypted link.
|
||||
|
||||
This obviously does not protect accepting party from the initiating party, if it can choose rendezvous server it controls. It also does not protect from the malicious rendezvous server that would collaborate with link observers. I think reunion doesn’t protect from it too.
|
||||
|
||||
But it does protect connection from whoever observes the link, particularly if this link only contains the bucket and the key is passed separately, via some other channel.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Sharing protocol ports with HTTPS
|
||||
|
||||
Some networks block all ports other than web ports, including port 5223 used for SMP protocol by default. Running SMP servers on a common web port 443 would allow them to work on more networks. The servers would need to provide an HTTPS page for browsers (and probes).
|
||||
|
||||
## Problem
|
||||
|
||||
Browsers and tools rely on system CA bundles instead of certificate pinning.
|
||||
The crypto parameters used by HTTPS are different from what the protocols use.
|
||||
Public certificate providers like LetsEncrypt can only sign specific types of keys and Ed25519 isn't one of them.
|
||||
|
||||
This means a server should distinguish browser and protocol clients and adjust its behavior to match.
|
||||
|
||||
## Solution
|
||||
|
||||
`tls` package has a server hook that allows producing a different set of `TLS.Credentials` according to a client-provided "Server Name Indication" extension.
|
||||
|
||||
Since LE certificates are only handed out to domain names, TLS client will be sending the SNI.
|
||||
However client transports are constructed over connected sockets and the SNI wouldn't be present unless explicitly requested.
|
||||
When a client sends SNI, then it's a browser and a web credentials should be used.
|
||||
Otherwise it's a protocol client to be offered the self-signed ca, cert and key.
|
||||
|
||||
When a transport colocated with a HTTPS, its ALPN list should be extended with `h2 http/1.1`.
|
||||
The browsers will send it, and it should be checked before running transport client.
|
||||
If HTTP ALPN is detected, then the client connection is served with HTTP `Application` instead (the same "server information" page).
|
||||
|
||||
If some client connects to server IP, doesn't send SNI and doesn't send ALPN, it will look like a pre-handshake client.
|
||||
In that case a server will send its handshake first.
|
||||
This can be mitigated by delaying its handshake and letting the probe to issue its HTTP request.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
An unmodified client should be able to use protocols on port 443 right away.
|
||||
|
||||
The switchover happens inside `runTransportServerState` before `runClient`:
|
||||
|
||||
```haskell
|
||||
runServer (tcpPort, ATransport t) = do
|
||||
-- ...
|
||||
runTransportServerState_ ss started tcpPort serverParams tCfg $ \socket h -> do -- expose raw socket for warp-tls internals to attach
|
||||
negotiated <- getSessionALPN
|
||||
if allowHTTP t && isHTTP negotiated -- only attempt the switch for the TLS transport
|
||||
then runHTTP socket (tlsContext h)-- ... collect data and produce values needed to run WAI Application
|
||||
else runClient serverSignKey t h `runReaderT` env -- performs serverHandshake etc as usual
|
||||
```
|
||||
|
||||
The web app and server live outside, so `runHttp` has to be provided by the `runSMPServer` caller.
|
||||
Additonally, Warp is using its `InternalInfo` object that's scoped to `withII` bracket.
|
||||
|
||||
```haskell
|
||||
runServer ini = do
|
||||
-- ...
|
||||
|
||||
runWebServer ini ServerInformation {config, information} $ if sharedHttps then Nothing else webHttpsParams -- suppress serving https
|
||||
if sharedHttps
|
||||
then withRunHTTP staticFilesPath \attachStatic -> runSMPServer cfg (Just attachStatic) -- provide wrapped application runner
|
||||
else runSMPServer cfg Nothing
|
||||
```
|
||||
|
||||
### Upstream
|
||||
|
||||
The implementation relies on a few modification to upstream code:
|
||||
- `warp-tls`: The library provides `httpOverTls`, but it wants to do handshake itself.
|
||||
Since we have to do the handshake to switch on ALPN, the setup function has to be split.
|
||||
This is a resonable change that may be upstreamed and nothing blocks us from using the recent version.
|
||||
- `warp`: Only the re-export of `serveConnection` is needed.
|
||||
Unfortunately the most recent `warp` version can't be used right away due to dependency cascade around `http-5` and `auto-update-2`.
|
||||
So a fork containing the backported re-export has to be used until the dependencies are refreshed.
|
||||
|
||||
|
||||
### TLS.ServerParams
|
||||
|
||||
When a server has port sharing enabled, a new set of TLS params is loaded and combined with transport params:
|
||||
|
||||
```haskell
|
||||
newEnv config = do
|
||||
-- ...
|
||||
tlsServerParams <- loadTLSServerParams caCertificateFile certificateFile privateKeyFile (alpn transportConfig)
|
||||
sharedServerParams <- forM ((,) <$> sharedHttpsCredentials config <*> alpn transportConfig) $ \((chain, key), alpn) ->
|
||||
let ca = Nothing -- It is possible to provide CA certificate, but it is typical for web server to use combined certificate chains
|
||||
loadHTTPSServerParams tlsServerParams ca chain key alpn
|
||||
```
|
||||
|
||||
`loadHTTPSServerParams` extends params with:
|
||||
1. `onALPNClientSuggest` hook gets `["h2", "http/1.1"]` added to the ALPN list which is now required.
|
||||
2. `onServerNameIndication` hook added, which upon detecting client SNI prepends the web credentials.
|
||||
3. `sharedCredentials = T.Credentials []` should be done to prevent transport credentials confusing browsers.
|
||||
But that aborts key exchange somewhere in tls internals, so disabled for now.
|
||||
As a workaround, another set of dummy credentials can be provided in the hope that any sane browser would reject them.
|
||||
Like, RC4 ciphers, "impossible" digest combination, etc.
|
||||
|
||||
### supportedParameters
|
||||
|
||||
TLS certificate chains provided by LetsEncrypt use ECDSA/P256 and that requires extending `supportedParameters` with things disabled in transports:
|
||||
|
||||
```haskell
|
||||
browserCiphers =
|
||||
[ TE.cipher_TLS13_AES128CCM8_SHA256
|
||||
, TE.cipher_ECDHE_ECDSA_AES128CCM8_SHA256
|
||||
, TE.cipher_ECDHE_ECDSA_AES256CCM8_SHA256
|
||||
]
|
||||
browserGroups =
|
||||
[ T.P256
|
||||
]
|
||||
browserSigs =
|
||||
[ (T.HashSHA256, T.SignatureECDSA),
|
||||
(T.HashSHA384, T.SignatureECDSA)
|
||||
]
|
||||
```
|
||||
|
||||
This may not be enough for other certificate providers.
|
||||
|
||||
## Configuration
|
||||
|
||||
> XXX: This is for the current implementation and should be updated.
|
||||
|
||||
Web certificate chain is picked up from the WEB section:
|
||||
|
||||
```ini
|
||||
[TRANSPORT]
|
||||
port: 443
|
||||
|
||||
[WEB]
|
||||
https: 443
|
||||
cert: /etc/opt/simplex/web.cert
|
||||
key: /etc/opt/simplex/web.key
|
||||
|
||||
# Alternatively, with a proper access configuration, the paths can point to the LE creds directly:
|
||||
# cert: /etc/letsencrypt/live/smp.hostname.tld/fullchain.pem
|
||||
# key: /etc/letsencrypt/live/smp.hostname.tld/privkey.pem
|
||||
```
|
||||
|
||||
When `TRANSPORT.port` matches `WEB.https` the transport server becomes shared.
|
||||
|
||||
Perhaps a more desirable option would be explicit configuration resulting in additional transported to run:
|
||||
|
||||
```ini
|
||||
[TRANSPORT]
|
||||
port: 5223 ; pure protocol transport
|
||||
# control_port: 5224
|
||||
shared_port: 443 ; variant 1: register in TRANSPORT
|
||||
|
||||
[WEB]
|
||||
https: 443
|
||||
cert: /etc/opt/simplex/web.cert
|
||||
key: /etc/opt/simplex/web.key
|
||||
# transport: on ; variant 2:
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
Serving static files and the protocols togother may pose a problem for those who currently use dedicated web servers as they should switch to embedded http handlers.
|
||||
|
||||
As before, using embedded HTTP server is increasing attack surface.
|
||||
|
||||
Users who want to run everything on a single host will have to add and extra IP address and bind servers to specific IPs instead of 0.0.0.0.
|
||||
An amalgamated server binary can be provided that would contain both SMP and XFTP servers, where transport will dispatch connections by handshake ALPN.
|
||||
|
||||
## Alternative: Use transports routable with reverse-proxies
|
||||
|
||||
An "industrial" reverse proxy may do the ALPN routing, serving HTTP by itself and delegating `smp` and `xftp` to protocol servers.
|
||||
Same with the `websockets`.
|
||||
|
||||
Since this in effect does TLS termination, the protocol servers will have to rely on credentials from protocol handshakes.
|
||||
@@ -0,0 +1,49 @@
|
||||
# iOS notifications delivery
|
||||
|
||||
## Problem
|
||||
|
||||
For iOS notifications to be delivered the client has to create credentials for notification subscription on SMP server using NKEY command and after that create a subscription on notification server using SNEW command. These two commands are sent in sequence, after the connections are created, and for it to happen the client needs to be online and in foreground.
|
||||
|
||||
iOS users tend to close the app when it is not used, and iOS has very limited permissions for background activities, so these notification subscriptions are created with a substantial delay, and notifications do not work.
|
||||
|
||||
This problem is distinct from and probably more common than other problems affecting notifications delivery described [here](./2024-07-06-ios-notifications.md).
|
||||
|
||||
## Solution
|
||||
|
||||
1. When the new connection is created, the client already knows if it needs to create notification subscription or not, based on the conversation setting (e.g., if the group is muted, the client will not create notification subscription as well.). We should extend NEW command to avoid the need to send additional NKEY command with an option to create notification subscription at the point where connection is created. NDEL would still be used to disable this notification, and NKEY will be used to re-enable it.
|
||||
|
||||
2. In the same way we stopped using SDEL command (NDEL sends notification DELD to subscribed notification server) to delete notificaiton subscriptions from notification server, we should delegate creating notification subscription on notification server to SMP servers. Clients could use keys agreed with ntf server for e2e encryption and for command authorization to encrypt and sign instruction to create notification subscription that will be forwarded to notification server using protocol similar to SMP proxies. This will avoid the need for clients to separately contact notification servers that won't happen until they are online.
|
||||
|
||||
3. Instead of making Ntf server trust DELD notifications, we could send deletion instructions signed by the client, which will only fail to send in case notification server is down (and they won't be sent later after server restart).
|
||||
|
||||
Cons:
|
||||
- If SMP servers were to retain in the storage the information about which notification server is used for which queue, it would reduce metadata privacy. While currently it is not an issue, as all notification servers are known and operated by us, once there are other client apps, this can be used for app users fingerprinting, which would act as a deterrence from using new apps – but only if app users use servers of operators who are different from the app provider. To mitigate it, we could only store it in server memory and include notification instruction in subscription commands (SUB) and include notification subscription status in SUB responses. We don't need to mitigate the problem of server being able to store this information, as messaging servers can observe which notification servers connect to them anyway.
|
||||
- If SMP server is restarted before the subscription request is forwared to the notification server, then it will have to be forwarded again, once the client subscribes. The problem here is that if the client is offline, it will neither subscribe to the queue to send notification subscription request, nor receive notifications from this queue. Storing notification server and subscription request would mitigate that, as in this case we could send all pending requests on server start, without depending on client subscriptions.
|
||||
- "Small" agent will need to support connections to ntf servers and manage workers that retry sending pending subscription requests.
|
||||
- Until the client learns the public keys of notification server, it will not be able to decrypt notifications. It potentially can be mitigated by using the public key of the server returned when token is created, in this way different client keys (per-queue) will be combined with the same ntf server key (per-token).
|
||||
|
||||
## Implementation details
|
||||
|
||||
1. NEW and NKEY commands will need to be extended to include notification subscription request. As the notifier ID needs to be sent to notification server, this notifier ID will have to be client-generated and supplied as part of NEW command.
|
||||
|
||||
now:
|
||||
|
||||
```haskell
|
||||
NEW :: RcvPublicAuthKey -> RcvPublicDhKey -> Maybe BasicAuth -> SubscriptionMode -> SenderCanSecure -> Command Recipient
|
||||
NKEY :: NtfPublicAuthKey -> RcvNtfPublicDhKey -> Command Recipient
|
||||
```
|
||||
|
||||
extended:
|
||||
|
||||
```haskell
|
||||
NEW :: RcvPublicAuthKey -> RcvPublicDhKey -> Maybe BasicAuth -> SubscriptionMode -> SenderCanSecure -> Maybe NtfRequest -> Command Recipient
|
||||
|
||||
data NtfRequest = NtfRequest NotifierId NtfPublicAuthKey RcvNtfPublicDhKey NtfServerRequest
|
||||
|
||||
data NtfServerRequest = NtfServerRequest NtfServer EncSingedNtfCmd
|
||||
|
||||
NKEY :: NtfPublicAuthKey -> RcvNtfPublicDhKey -> Maybe NtfServerRequest -> Command Recipient
|
||||
-- NotifierID is passed in entity ID field of the transmission
|
||||
```
|
||||
|
||||
2. Notification server will need to support an additional command to receive "proxied" subscription commands, `SFWD`, that would include `NtfServerRequest`. This command can include both `SNEW` and `SDEL` commands.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Expiring messages in journal storage
|
||||
|
||||
## Problem
|
||||
|
||||
The journal storage servers recently migrated to do not delete delivered or expired messages, they only update pointers to journal file lines. The messages are actually deleted when the whole journal file is deleted (when fully deleted or fully expired).
|
||||
|
||||
The problem is that in case the queue stops receiving the new messages then writing of messages won't switch to the new journal file, and the current journal file containing delivered or expired messages would never be deleted.
|
||||
|
||||
## Solution
|
||||
|
||||
Remove current journal file and update queue_state.log during message expiration of "idle" queue (that is, without any new messages received or delivered within 3 hours) in case when:
|
||||
- the queue is "empty" after the expiration
|
||||
- the queue contains only quota marker(s), in which case move them to a new journal file and update the queue_state accordingly. Quota markers can be kept indefinitely to prevent writing the new messages to the dormant queues that reached capacity, so it's important to handle this case.
|
||||
|
||||
Also remove current journal file when the queue is opened in case it is empty (as it would not be ever expired in case it remains empty), and also update queue_state.log
|
||||
@@ -0,0 +1,58 @@
|
||||
# Blob extensions for SMP queues 2 and queue storage
|
||||
|
||||
This document evolves the design proposed [here](./2024-09-09-smp-blobs.md).
|
||||
|
||||
## Problems
|
||||
|
||||
In addition to problems in the first doc, we have these issues with in-memory queue record storage:
|
||||
- many queues are idle or rarely used, but they are loaded to memory, and currently just loading all queues uses 20gb RAM on each server, and takes 10 min to process, increasing downtimes during restarts.
|
||||
- adding blobs to memory would make this problem much worse.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
Move queues to the same journalling approach as [used for messages](./2024-09-01-smp-message-storage.md) now, with independent file names in the same folders.
|
||||
|
||||
Each queue change would be logged to its own file, and every time the queue is opened the whole file will be read and compacted to a single line - replacing one store log for all queues, with individual log files for each queue.
|
||||
|
||||
Queue deletion would not be making a record in the file, instead it would be deleting the entire folder - it would reduce retention period for any metadata of deleted queues.
|
||||
|
||||
We could additionally record deletions to the central log, for debugging, and reset it on every start. But in this case we should not remove folders at the point of deletion, but rather mark them as deleted and delete on restart. TBC
|
||||
|
||||
It would also allow simplifying blob storage by having only one blob per queue - for example, limied to 16kb (a bit smaller to fit in block) for contact address queues and 4-8kb for invitations (to fit PQ keys and conversation preferences).
|
||||
|
||||
We would also need to be able to lookup recipient ID via sender/notifier/link IDs.
|
||||
|
||||
One possible solution is to use and load to memory a central index file. But it is likely to also consume a lot of memory and result in slow starts.
|
||||
|
||||
Another solution that is probably better is to use the same folder structure and put notifier/sender/link files with the ID of the recipient queue inside the files. So to locate recipient queue the sender would have to locate folder containing the reference file pointing to the recipient queue and then to locate the actual queue data.
|
||||
|
||||
## Implementation details
|
||||
|
||||
Each queue folder would these files:
|
||||
|
||||
- queue_state.log (and timestamped backups) - to store pointers to message journals (already implemented)
|
||||
- messages.randomBase64.log - message journals (already implemented)
|
||||
- queue_rec.log (and timestamped backups) - to log complete queue record every time it is changed (so only the last line needs to be read following the same logic as with queue_state.log, to prevent file corruption).
|
||||
- blob.data, blob.data.bak, blob.timestamp.data - files for data blobs (to make sure some copy of this file is readable/correct in case of write corruption) - the same two step overwrite process will be used as currently with store log compacting:
|
||||
- on write: 1. if file exists, move it to .bak, 2. store new blob to .data, 3. move .bak to .timestamp.data
|
||||
- on read: 1. if .bak exists, move it to .data 2. use .data
|
||||
|
||||
Additional suggestion to reduce probability of queue_state.log and queue_rec.log file corruption is to do one of the following:
|
||||
- log end of lines in the beginning of the output, not in the end, to prevent the last line from being corrupted in case the previous line was not fully stored. The downside is that the file will not be EOL terminated, and there will be no confirmation that the output was fully made.
|
||||
- log EOL both in the beginning and at the end of output, and ignore empty lines in between - this would both confirm that the last line is fully logged and prevent corruption of the next line in case it was not.
|
||||
- check the last byte of the file and log EOL if it is not EOL. Probably cleanest approach, but with a small performance cost.
|
||||
|
||||
If queue folder is a reference to the queue, it may have one of these files:
|
||||
- notifier.id
|
||||
- sender.id
|
||||
- link.id
|
||||
|
||||
These files would contain a one line with the recipient ID of the queue. These files would never change, they can only be deleted when queue is deleted or when notifier/link is deleted.
|
||||
|
||||
There is logic in code preventing using the same ID in different contexts, and the ID size is large enough to make any collisions unlikely (192 bits), so with correctly working code the queue folder would either have one of reference files, and nothing else, or the queue and message files from the beginning of this section. But even if the same ID is re-used in different context, it should not cause any problems as file names don't overlap.
|
||||
|
||||
While we could store different types of references in different types of folders, it would have additional costs of maintaining 4 folder hierarchies. Instead we could use the fact that it is one hierarchy to prevent using the same ID in different contexts.
|
||||
|
||||
## Protocol
|
||||
|
||||
The only change in protocol is that there will be only one blob per queue, without markers (see the previous doc). Otherwise the protocol and proposed privacy improvement seem reasonable.
|
||||
@@ -0,0 +1,290 @@
|
||||
# Protocol changes for creating and connecting to SMP queues
|
||||
|
||||
## Problems
|
||||
|
||||
This change is related to these problems:
|
||||
- differentiating queue retention time,
|
||||
- supporting MITM-resistant short connection links,
|
||||
|
||||
This RFC is based on the previous discussions about short links, blob storage and notifications ([1](./2024-06-21-short-links.md), [2](./2024-09-09-smp-blobs.md), [3](./2024-11-25-queue-blobs-2.md), [4](./2024-09-25-ios-notifications-2.md)).
|
||||
|
||||
SMP protocol supports two types of queues - queues to send messages (messaging queues) and queues to send invitations to connect (contact queues). While SMP protocol was originally "unaware" of these queue types, it could differentiate it by message flow, and with the recent addition of SKEY command to allow securing the queue by the sender this difference became persistent.
|
||||
|
||||
Simply designating queue types would allow to use this information to decide for how long to retain queues, and potentially extending it:
|
||||
- unsecured 1-time invitation queues with sndSecure (support of securing by sender) - e.g., 3 months.
|
||||
- contact address queues without sndSecure - e.g., 3 years without activity.
|
||||
- Possibly, "queues" that prohibit messages and used only as blob storage - they would be used to store group profiles and super-peer addresses for the group.
|
||||
|
||||
## Design objectives
|
||||
|
||||
We want to achieve these objectives for short links and associated queue data:
|
||||
1. no possibility to provide incorrect SenderId inside link data (e.g. from another queue).
|
||||
2. link data cannot be accessed by the server unless it has the link.
|
||||
3. prevent MITM attack by the server, including the server that obtained the link.
|
||||
4. prevent changing of connection request by the user (to prevent MITM via break-in attack in the originating client).
|
||||
5. for one-time links, prevent accessing link data by link observers who did not compromise the server.
|
||||
6. allow changing the user-defined part of link data.
|
||||
7. avoid changing the link when user-defined part of link data changes, while preventing MITM attack by the server on user-defined part, even if it has the link.
|
||||
8. retain the quality that it is impossible to check the existence of secured queue from having any of its temporary visible IDs (sender ID and link ID in 1-time invitations) - it requires that these IDs remain server-generated (contrary to the previous RFCs).
|
||||
|
||||
To achieve these objectives the queue data will include fixed (immutable) and user-defined (mutable) parts.
|
||||
|
||||
Fixed part would include:
|
||||
- full connection request (the current long link with all keys, including PQ keys). This includes SenderId that must match server response.
|
||||
- public signature key to verify mutable part of link data.
|
||||
|
||||
Signed mutable part would include:
|
||||
- any links to chat relays that should be contacted instead of this queue (not in this RFC), to allow delegating group connections and contact request connections to prevent spam, hiding online presence, etc.
|
||||
- and user-defined data - user profile or group profile, chat preferences, welcome message, etc.
|
||||
|
||||
The link itself should include both the key and auth tag from the encryption of immutable part. Accessing one-time link data should require providing sender key and signing the command (`LKEY`).
|
||||
|
||||
## Solution
|
||||
|
||||
Current NEW and NKEY commands:
|
||||
|
||||
```haskell
|
||||
NEW :: RcvPublicAuthKey -> RcvPublicDhKey -> Maybe BasicAuth -> SubscriptionMode -> SenderCanSecure -> Command Recipient
|
||||
|
||||
-- | Queue IDs and keys, returned in IDS response
|
||||
data QueueIdsKeys = QIK
|
||||
{ rcvId :: RecipientId,
|
||||
sndId :: SenderId,
|
||||
rcvPublicDhKey :: RcvPublicDhKey,
|
||||
sndSecure :: SenderCanSecure
|
||||
}
|
||||
```
|
||||
|
||||
Proposed NEW command replaces SenderCanSecure with QueueMode, adds link data, and combines NKEY command:
|
||||
|
||||
```haskell
|
||||
NEW :: NewQueueRequest -> Command Recipient
|
||||
|
||||
data NewQueueReq = NewQueueReq
|
||||
{ rcvAuthKey :: RcvPublicAuthKey,
|
||||
rcvDhKey :: RcvPublicDhKey,
|
||||
auth_ :: Maybe BasicAuth,
|
||||
subMode :: SubscriptionMode,
|
||||
queueReqData :: Maybe QueueReqData,
|
||||
}
|
||||
|
||||
-- QRMessaging implies that sender can secure the queue.
|
||||
-- LinkId is not used with QRMessaging, to prevent the possibility of checking when connection is established by re-using the same link ID when creating another queue – the creating would have to fail if it is used.
|
||||
-- LinkId is required with QRContact, to have shorter link - it will be derived from the link_uri. And in this case we do not need to prevent checks that this queue exists.
|
||||
data QueueReqData
|
||||
= QRMessaging (Maybe (SenderId, QueueLinkData))
|
||||
| QRContact (Maybe (LinkId, (SenderId, QueueLinkData)))
|
||||
|
||||
-- SenderId should be computed client-side as the first 24 bytes of sha3-384(correlation_id),
|
||||
-- The server must verify it and reject if it is not.
|
||||
-- It allows to include sender ID inside encrypted associated link data as part of full connection URI without requesting it from the server, but prevents checking if a given sender ID exists (queue creation would fail for a duplicate sender ID), as sha3-384 derivation is not reversible.
|
||||
type QueueLinkData = (EncFixedLinkData, EncUserDataBytes)
|
||||
|
||||
type EncFixedLinkData = ByteString
|
||||
|
||||
type EncUserDataBytes = ByteString
|
||||
|
||||
-- We need to use binary encoding for ConnectionRequestUri to reduce its size
|
||||
-- The clients would reject changed immutable data and
|
||||
-- ConnectionRequestUri where server or SenderId of the queue do not match.
|
||||
data FixedLinkData c = FixedLinkData
|
||||
{ agentVRange :: VersionRangeSMPA,
|
||||
rootKey :: C.PublicKeyEd25519,
|
||||
connReq :: ConnectionRequestUri c
|
||||
}
|
||||
|
||||
data ConnLinkData c where
|
||||
InvitationLinkData :: VersionRangeSMPA -> UserLinkData -> ConnLinkData 'CMInvitation
|
||||
ContactLinkData ::
|
||||
{ agentVRange :: VersionRangeSMPA,
|
||||
-- direct connection via connReq in fixed data is allowed.
|
||||
direct :: Bool,
|
||||
-- additional owner keys to sign changes of mutable data.
|
||||
owners :: [OwnerAuth],
|
||||
-- alternative addresses of chat relays that receive requests for this contact address.
|
||||
relays :: [ConnShortLink 'CMContact],
|
||||
userData :: UserLinkData
|
||||
} -> ConnLinkData 'CMContact
|
||||
|
||||
newtype UserLinkData = UserLinkData ByteString
|
||||
|
||||
-- | Updated queue IDs and keys, returned in IDS response
|
||||
data QueueIdsKeys = QIK
|
||||
{ rcvId :: RecipientId, -- server-generated
|
||||
sndId :: SenderId, -- server-generated
|
||||
rcvPublicDhKey :: RcvPublicDhKey,
|
||||
sndSecure :: SenderCanSecure, -- possibly, can be removed? or implied?
|
||||
linkId :: Maybe LinkId -- server-generated
|
||||
}
|
||||
```
|
||||
|
||||
In addition to that we add the command allowing to update and also to retrieve and secure the queue and get link data in one request, to have only one request:
|
||||
|
||||
```haskell
|
||||
-- This command allows to set all data or to update mutable part of contact address queue.
|
||||
-- This command should fail on queues that support sndSecure and also on new queues created with QRMessaging.
|
||||
-- This should fail if LinkId or immutable part of data is changed with the update, but will succeed if only mutable part is updated, so it can be retried.
|
||||
-- Entity ID is RecipientId.
|
||||
-- The response to this command is `OK`.
|
||||
LSET :: LinkId -> QueueLinkData -> Command Recipient
|
||||
|
||||
-- Delete should link and associated data
|
||||
-- Entity ID is RecipientId
|
||||
LDEL :: Command Recipient
|
||||
|
||||
-- To be used with 1-time links.
|
||||
-- Sender's key provided on the first request prevents observers from undetectably accessing 1-time link data.
|
||||
-- If queue mode is QRContact (and queue does NOT allow sndSecure) the command will fail, same as SKEY.
|
||||
-- Once queue is secured, the key must be the same in subsequent requests - to allow retries in case of network failures, and to prevent passive attacks.
|
||||
-- The difference with securing queues is that queues allow sending unsecured messages to queues that allow sndSecure (for backwards compatibility), and 1-time links will NOT allow retrieving link data without securing the queue at the same time, preventing undetected access by observers.
|
||||
-- Entity ID is LinkId
|
||||
LKEY :: SndPublicAuthKey -> Command Sender
|
||||
|
||||
-- If queue mode is QRMessaging the command will fail.
|
||||
-- Entity ID is LinkId
|
||||
LGET :: Command Sender
|
||||
|
||||
-- Response to LGET and LSET
|
||||
-- Entity ID is the same as in the command
|
||||
LNK :: SenderId -> QueueLinkData -> BrokerMsg
|
||||
```
|
||||
|
||||
To both include sender_id into the full link before the server response, and to prevent "oracle attack" when a failure to create the queue with the supplied `sender_id` can be used as a proof of queue existence, it is proposed that `sender_id` is computed client-side as the first 24 bytes of 48 in `sha3-384(correlation_id)` and validated server-side, where `corelation_id` is the transmission correlation ID.
|
||||
|
||||
To allow retries, every time the command is sent a new random `correlation_id` and new `sender_id` (and for contact queue, also `link_id`, which would be random as it is derived from hash of fixed link data that includes a random signature key) should be used on each attempt, because other IDs would be generated randomly on the server, and in case the previous command succeeded on the server but failed to be communicated to the client, the retry will fail if the same ID is used.
|
||||
|
||||
Alternative solutions that would allow retries that were considered and rejected:
|
||||
- additional request to save queue data, after `sender_id` is returned by the server. The scenarios that require short links are interactive - creating user addresses and 1-time invitations - so making two requests instead of one would make the UX worse.
|
||||
- include empty sender_id in the immutable data and have it replaced by the accepting party with `sender_id` received in `LINK` response - both a weird design, and might create possibility for some attacks via server, especially for contact addresses.
|
||||
- making NEW commands idempotent. Doing it would require generating all IDs client-side, not only `sender_id`. It increases complexity, and it is not really necessary as the only scenarios when retries are needed are async NEW commands, that do not require short links. For future short links of chat relays the retries are much less likely, as chat relays will have good network connections.
|
||||
|
||||
## Algorithm to prepare and to interpret queue link data.
|
||||
|
||||
For contact addresses this approach follows the design proposed in [Short links](./2024-06-21-short-links.md) RFC - when link id is derived from the same random binary as key. For 1-time invitations link ID is independent and server-generated, to prevent existence checks (oracle attack).
|
||||
|
||||
This scheme results in 32 byte binary size for contact addresses and 56 bytes for 1-time invitation links.
|
||||
|
||||
For fixed link data.
|
||||
|
||||
1. Generate random `nonce` (also used as a correlation ID for server command) and signature key (public `rootKey` included in fixed data).
|
||||
2. Compute sender ID from `nonce` as the first 24 bytes of sha3-384 of `nonce`.
|
||||
3. Generate other keys for queue address, including queue e2e encryption keys and double ratchet connection e2e encryption keys.
|
||||
4. Construct the full connection address to be included in fixed data.
|
||||
5. `link_key = SHA3-256(fixed_data)` - used as part of the link, and to derive the key to encrypt content.
|
||||
6. HKDF:
|
||||
1) contact address: `(link_id, key) = HKDF(link_key, 56 bytes)`.
|
||||
2) 1-time invitation: `key = HKDF(link_key, 32 bytes)`, `link-id` - server-generated.
|
||||
7. Encrypt: `(ct1, tag1) = secret_box(fixed_data, key, nonce1)`, where `nonce1` is a random nonce
|
||||
5. Store: `(nonce1, ct1, tag1)` stored as fixed link data.
|
||||
|
||||
For mutable user data:
|
||||
|
||||
1. Random `nonce2` and the same key are used.
|
||||
2. Sign `user_data` with key included in `fixed_data`.
|
||||
3. Encrypt: `(ct2, tag2) = secret_box(signed_used_data, key, nonce2)`.
|
||||
4. Store: `(nonce2, ct2, tag2)`
|
||||
|
||||
Link recipient:
|
||||
|
||||
1. Receives `link_key` in the link, for 1-time invitations also `link_id`.
|
||||
2. HKDF:
|
||||
1) contact address: `(link_id, key) = HKDF(link_key, 56 bytes)`.
|
||||
2) 1-time invitation: `key = HKDF(link_key, 32 bytes)`.
|
||||
3. Retrieves via `link_id`: `(nonce1, ct1, tag1)` and `(nonce2, ct2, tag2)`:
|
||||
1) contact address: `LGET` command, that allows retrieving link data multiple times.
|
||||
2) 1-time invitation: `LKEY` command, that non-optionally secures the queue, and only allows repeated link data retrievals if the same sender's key is provided and signs the transmission. This prevents link data retrieval by link observers.
|
||||
4. Decrypt: `(signature1, fixed_data) = decrypt (nonce1, ct1, tag1)`.
|
||||
5. Verify: `SHA3-256(fixed_data) == link_key`, abort if not.
|
||||
6. Decrypt: `(signature2, used_data) = decrypt(nonce2, ct2, tag2)`.
|
||||
7. Verify signatures using key in the fixed data, abort if they don't match.
|
||||
|
||||
While using content hash as encryption key is unconventional, it is not completely unusual - e.g., it is used in convergent encryption (although in our case using random nonce makes it not convergent, but other use cases suggest that this approach preserves encryption security). It is particularly acceptable for our use case, as `fixed_data` contains mostly random keys.
|
||||
|
||||
## Threat model
|
||||
|
||||
**Compromised SMP server**
|
||||
|
||||
can:
|
||||
- delete link data.
|
||||
- hide link data selectively for some or for all requests.
|
||||
|
||||
cannot:
|
||||
- undetectably replace link data, even if it has the link (objective 3).
|
||||
- access unencrypted link data, whether it was or was not accessed by the accepting party, provided it has no link (objective 2).
|
||||
- observe IP addresses of the users accessing link data, if private routing is used.
|
||||
|
||||
**Passive observer who observed short link**:
|
||||
|
||||
can:
|
||||
- access original unencrypted link data for contact address links.
|
||||
|
||||
cannot:
|
||||
- undetectably access observed 1-time link data, accessing the link would make the link inaccessible to the sender (objective 5).
|
||||
- undetectably check the existence of messaging queue or 1-time link (objective 8).
|
||||
- replace or delete the link data.
|
||||
|
||||
**Queue owner who did not compromise the server**:
|
||||
|
||||
cannot:
|
||||
- redirect connecting user to another queue, on the same or on another server (objective 1).
|
||||
- replace connection request in the link (objective 4).
|
||||
|
||||
## Correlation of design objectives with design elements
|
||||
|
||||
1. The presence of `SenderId` in `LNK` response from the server.
|
||||
2. Encryption of link data with crypto_box.
|
||||
3. Deriving encryption key from the hash of fixed data prevents it being modified by the server - any change would be detected and rejected by the client, as the hash of fixed data won't match the link. Signature verification with the key from fixed data, and signing of mutable data prevents server modification of mutable data.
|
||||
4. No server command to change fixed data once it's set. Also, changing fixed data would require changing the link.
|
||||
5. 1-time link data can only be accessed with `LKEY` command, that while allows retries to mitigate network failures, will require the same key for retries.
|
||||
6. `LSET` command.
|
||||
7. The link is derived from fixed data only, so it does not change when mutable link data changes. Mutable part is signed preventing server MITM attacks.
|
||||
8. SenderId is derived from request correlation ID, so it cannot be arbitrary defined to check existence of some known queue. LinkId for 1-time invitation is generated server-side, so it cannot be provided by the client when creating the queues to check if these IDs are used.
|
||||
|
||||
## Syntax for short links
|
||||
|
||||
The syntax:
|
||||
|
||||
```abnf
|
||||
shortConnectionLink = uriAuthority "/" linkUri [ "?" param *( "&" param ) ]
|
||||
uriAuthority = %s"https://" smpServerHost / "simplex:" ; using simplex: scheme requires including host in the parameter hostParam
|
||||
smpServerHost = <hostname> ; RFC1123, RFC5891
|
||||
linkUri = %s"i#" oneTimeLink / contactType "#" contactLink
|
||||
contactType = %s"a" / %s"g" / %s"c" ; contact / group / channel address, respectively
|
||||
oneTimeLink = <base64url(linkId)> "/" <base64url(linkKey)> ; 56 bytes / 75 base64 encoded characters
|
||||
contactLink = <base64url(linkKey)> ; 32 bytes / 43 base64 encoded characters
|
||||
; linkId - 192 bits/24 bytes
|
||||
; linkKey - 256 bits/32 bytes
|
||||
|
||||
param = hostsParam / portParam / certHashParam
|
||||
hostsParam = %s"h=" host *("," host) ; additional hostnames, e.g. onion
|
||||
portParam = %s"p=" 1*DIGIT ; server port
|
||||
certHashParam = %s"c=" <base64url(server offline certificate fingerprint)>
|
||||
```
|
||||
|
||||
To have shorter links fingerprint and additional server hostnames do not need to be specified for pre-configured servers, even if they are disabled - they can be used from the client code. Any user defined servers will require including additional hosts and server fingerprint.
|
||||
|
||||
Example one-time link for preset server (104 characters):
|
||||
|
||||
```
|
||||
https://smp12.simplex.im/i#abcdefghij0123456789abcdefghij01/23456789abcdefghij0123456789abcdefghij01234
|
||||
```
|
||||
|
||||
Example contact link for preset server (71 characters):
|
||||
|
||||
```
|
||||
https://smp12.simplex.im/c#abcdefghij0123456789abcdefghij0123456789abc
|
||||
```
|
||||
|
||||
Example contact link for user-defined server (with fingerprint, but without onion hostname - 117 characters):
|
||||
|
||||
```
|
||||
https://smp1.example.com/c#abcdefghij0123456789abcdefghij0123456789abc?c=0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU
|
||||
```
|
||||
|
||||
Example contact link for user-defined server (with fingerprint ant onion hostname - 182 characters):
|
||||
|
||||
```
|
||||
https://smp1.example.com/c#abcdefghij0123456789abcdefghij0123456789abc?c=0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU&h=beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion
|
||||
```
|
||||
|
||||
For the links to work in the browser the servers must provide server pages.
|
||||
@@ -0,0 +1,93 @@
|
||||
# New notifications protocol
|
||||
|
||||
## Problem
|
||||
|
||||
iOS notifications have these problems:
|
||||
- iOS notification service crashes exceeding memory limit. This is being addressed by changes in GHC RTS.
|
||||
- there is a large number of connections, because each member in a group requires individual connection. This will improve with chat relays when each group would require 2-3 connections.
|
||||
- some notification may be not shown if notification with reply/mention is skipped, and instead some other message is delivered, which may be muted. This would not improve without some changes, as notifications may be skipped anyway.
|
||||
- client devices delay communication with ntf server because it is done in background, and by that time the app may be suspended.
|
||||
- notification server represents a bottleneck, as it has to be owned by the app vendor, and the current design when ntf server subscribes to notifications scales very badly.
|
||||
|
||||
This RFC is based on the previous [RFC related to notifications](./2024-09-25-ios-notifications-2.md).
|
||||
|
||||
## Solution
|
||||
|
||||
As notification server has to know client token and currently it associates subscriptions with this token anyway, we are not gaining any privacy and security by using per-subscription keys - both authorization and encryption keys of notification subscription can be dropped.
|
||||
|
||||
We still need to store the list of queue IDs associated with the token on the notification server, but we do not need any per-queue keys on the notification server, and we don't need subscriptions - it's effectively a simple set of IDs, with no other information.
|
||||
|
||||
In this case, when queue is created the client would supply notifier ID - it has to be derived from correlation ID, to prevent existense check (see previous RFC). As we also supply sender ID, instead of deriving it as sha3-192 of correlation ID, they both can be derived as sha3-384 and split to two IDs - 24 bytes each.
|
||||
|
||||
The notification server will maintain a rotating list of server keys with the latest key communicated to the client every time the token is registered and checked. The keys would expire after, say, 1 week or 1 month, and removed from notification server on expiration.
|
||||
|
||||
The packet containing association between notifier queue ID and token will be crypto_box encrypted using key agreement between identified notification server master key and an ephemeral per packet (effectively, per-queue) client-key.
|
||||
|
||||
Deleting the queue may also include encrypted packet that would verify that the client deleted the queue.
|
||||
|
||||
Instead of notification server subscribing to the notifications creating a lot of traffic for the queues without messages, the SMP server would push notifications via NTF server connection (whether via NTF or via SMP protocol). This could be used as a mechanism to migrate existing queues when with the next subscription the notification server would communicate it's address to SMP server and this association would be stored together with the queue.
|
||||
|
||||
## Protocol design
|
||||
|
||||
Additional/changed SMP commands:
|
||||
|
||||
```haskell
|
||||
-- register notification server
|
||||
-- should be signed with server key
|
||||
NSRV :: NtfServerCreds -> Command NtfServer
|
||||
|
||||
-- response
|
||||
NSID :: NtfServerId -> BrokerMsg
|
||||
|
||||
-- to communicate which server is responsible for the queue
|
||||
-- should be signed with queue key
|
||||
NSUB :: Maybe NtfServerId -> Command Notifier
|
||||
|
||||
-- subscribe to notificaions from all queues associated with the server
|
||||
-- should be signed with server key
|
||||
-- entity ID - NtfServerId
|
||||
NSSUB :: Command NtfServer
|
||||
|
||||
data NtfServerCreds = NtfServerCreds
|
||||
{ server :: NtfServer,
|
||||
-- NTF server certificate chain that should match fingerpring in address
|
||||
cert :: X.CertificateChain,
|
||||
-- server autorizatio key to sign server subscription requests
|
||||
authKey :: X.SignedExact X.PubKey
|
||||
}
|
||||
|
||||
-- entity ID is recipient ID
|
||||
NSKEY :: NtfSubscription -> Command Recipient
|
||||
|
||||
data NtfSubscription = NtfSubscription
|
||||
-- key to encrypt notifications e2e with the client
|
||||
{ ntfPubDbKey :: RcvNtfPublicDhKey,
|
||||
ntfServer :: NtfServer,
|
||||
-- should be linked to correlation ID to prevent existense check
|
||||
-- the ID sent to notification server could be its hash?
|
||||
ntfId :: NotifierId,
|
||||
encNtfTokenAssoc :: EncDataBytes
|
||||
}
|
||||
|
||||
-- before the encryption - equivalent to NSUB command, but without key to authorize requests to specific queue
|
||||
data NtfTokenAssoc = NtfTokenAssoc
|
||||
{ signature :: SignatureEd25519,
|
||||
tknId :: NtfTokenId,
|
||||
ntfQueue :: SMPQueueNtf
|
||||
}
|
||||
```
|
||||
|
||||
SMP server will need to maintain the list of Ntf servers and their credentials, and when NSSUB arrives to make only one subscription. When message arrives it would deliver notification to the correct connection via queue / ntf server association.
|
||||
|
||||
Ntf server needs to maintain three indices to the same data:
|
||||
- `(smpServer, queueId) -> tokenId` - to deliver notification to the correct token
|
||||
- `tokenId -> [smpServer -> [queueId]]` - to remove all queues when token is removed, and to store/update these associations effficiently - store log may have one compact line per token (after compacting), or per token/server combination.
|
||||
- `[smpServer]` - array of SMP servers to subscribe to.
|
||||
|
||||
## Mention notifications
|
||||
|
||||
Currently we are marking messages with T (true) for messages that require notifications and F (false) for messages that don't require. Sender does not know whether the recipient has notifications disabled, enabled or in mentions-only mode.
|
||||
|
||||
The proposal is to:
|
||||
- add additional values to this metadata, e.g. 2 (priority) and 3 (high priority) (and T/F could be sent as 0/1 respectively) - that is, to deliver notifications even if notifications are generally disabled (they can still be further filtered by the client).
|
||||
- instead of deleting notification credentials when notifications are disabled - which is costly - communicate to SMP server the change of notificaion priority level, e.g. the client could set minimal notification priority to deliver notifications, where 0 would mean disabling it completely, 1 enable for all, 2 for priority 2+, 3 for priority 3. The downside here is that it could be used for timing correlation of queues in the group, but it already can be used on bulk deletions of ntf credentials for these queues and when sending messages.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Using short links as group links
|
||||
|
||||
## Problem
|
||||
|
||||
To use the short links for groups these problems has to be / can be solved:
|
||||
1. recognizing link as a group link.
|
||||
2. permanent link with the ability to change chat relays.
|
||||
3. binding owners signatures to the link.
|
||||
4. allowing to add/remove owners, both to share ownership and for reliability in case of one owner losing keys/access.
|
||||
|
||||
While current short links solve problems 1-3 (via contact type, and via extension of user data in the link), the problem 4 is solved only partially.
|
||||
|
||||
We could include the current list of root owners in the user data, and we could send any history of ownership changes from this baseline as a short blockchain on joining the group, we still requrie one master owner to retain access to the queue associated with the group.
|
||||
|
||||
## Possible solution approaches
|
||||
|
||||
1. "Kick this can down the road" - ignore this problem until there is a namespace, and a group name can be associated with multiple queues.
|
||||
|
||||
Pros: simple and reasonable, and it suggests postponing multisig for owners too. The users can still see the list of owners and their keys in user data of the link, and receive admin roster signed by owners on joining.
|
||||
|
||||
Cons: if this "master owner" loses the access to the device, no further changes to group profile will be possible.
|
||||
|
||||
2. The queue access can be shared by sharing the key and recipient IDs with all owners.
|
||||
|
||||
The problems:
|
||||
- preventing MITM attack between owners (this protect exists for other solutions too).
|
||||
- protecting these credentials from chat relays. So somehow there should be direct key agreement between members allowing to send e2e encrypted message inaccessible to chat relays.
|
||||
|
||||
Pros: simpler than alternatives, and still provides protection against losing the key.
|
||||
Cons:
|
||||
- quite clunky, and requires the new primitive anyway (e2e encryption).
|
||||
- no multisig
|
||||
|
||||
This could possibly be evolved into the requirement to have a direct connection with other owners, and verifying the security code before they have access to group.
|
||||
|
||||
3. Allow "joint management" of SMP queues.
|
||||
|
||||
SMP servers can support multiple recipients for contact queues:\
|
||||
- subscription would be possible to the "subscriber recipient".
|
||||
- all other changes (update data, change subscriber recipient, add or remove recipients) would require multiple recipient signatures on SMP command in line with n-of-m multisig rules, that the command sender would have to collect out-of-band (from SMP protocol point of view).
|
||||
|
||||
Pros: allows joint ownership, and protects from losing access to master owner device.
|
||||
Cons:
|
||||
- complicates queue abstraction with approach that is not needed for most queues.
|
||||
- still retains the server as a single point of failure.
|
||||
|
||||
4. Introduce "group" as a new type of entity managed by SMP servers.
|
||||
|
||||
SMP servers would provide a separate set of commands for managing group records that would include in an encrypted container:
|
||||
- the group profile
|
||||
- the list of chat relay links
|
||||
- the list of owner member IDs with their public keys
|
||||
- multisig rules
|
||||
- alternative group entity locations
|
||||
- possibly, a globally unique group identity (as the hash of the initial/seed group data).
|
||||
|
||||
While the server domain would be used as the hostname in group link, it may contain alternative hosts (not just hostnames of the same server), both in the link and in the group record data.
|
||||
|
||||
Pros: separates additional complexity to where it is needed, allowing reliability and redundancy for group ownership.
|
||||
Cons: complexity, coupling between SMP and chat protocol.
|
||||
|
||||
## Design for channel/group as a separate queue mode
|
||||
|
||||
Option 1.
|
||||
|
||||
A queue mode "channel" when owners are represented by their individual queues (either a separate mode, or a submode of "channel", or just normal contact address queues). In this case sending message to channel queue would broadcast message to queue owners, without exposing even the number of owners.
|
||||
|
||||
Pros:
|
||||
- allows chat relays to send messages to all owners (e.g., channel can be secured with the list of snd keys, one per relay).
|
||||
- quite easy to evolve from the current design.
|
||||
- extensible.
|
||||
Cons:
|
||||
- close to "solution in search of a problem".
|
||||
- does not require data model changes - channel queue would simply have a list of owner "recipient IDs", and each owner queue would also point to channel.
|
||||
|
||||
Option 2.
|
||||
|
||||
Also a separate queue mode "channel", but instead of having a linked owner queues, it would simply maintain a list of owner keys to maintain the data. In this case, messages cannot be sent to this "queue" at all.
|
||||
|
||||
Pros:
|
||||
- simpler design.
|
||||
- we could allow sending messages to it too, with the "main" owner receiving them. This could be negotiated in the protocol.
|
||||
- it may be easier to migrate the current groups, as the admin link would be this queue (although for public groups in directory it would have to be recreated anyway).
|
||||
- Possibly, when queue is created there should be a flag whether it should accept unsigned messages - then contact addresses would be created with unsigned messages ON, messages queues, once SKEY is universally supported, with unsigned messages OFF, and channel queues with unsigned messages OFF too for new public queues.
|
||||
Cons:
|
||||
- if no messages are accepted, this is not even a queue.
|
||||
- no way to directly contact owners (maybe it is not a downside, as for relays there would be a communication channel anyway as part of the group).
|
||||
|
||||
Option 2 looks more simple and attractive, implementing server broadcast for SMP seems unnecessary, as while it could have been used for simple groups, it does not solve such problems as spam and pre-moderation anyway - it requires a higher level protocol.
|
||||
|
||||
The command to update owner keys would be `RKEY` with the list of keys, and we can make `NEW` accept multiple keys too, although the use case here is less clear.
|
||||
|
||||
## Multiple owners managing queue data.
|
||||
|
||||
Option 1: Use the same keys in SMP as when signing queue data.
|
||||
|
||||
Option 2: Use different keys.
|
||||
|
||||
The value here could be that the server could validate these signatures too, and also maintain the chain of key changes. While tempting, it is probably unnecessary, and this chain of ownership is better to be maintained on chat relay level, as there are no size constraints on the size of this chain. Also, it is better for metadata privacy to not couple transport and chat protocol keys.
|
||||
|
||||
We still need to bind the mutable data updates to the "genesis" signature key (the one included in the immutable data).
|
||||
|
||||
The proposed design:
|
||||
|
||||
- when mutable data is signed by genesis key, then it is bound, and no changes is needed.
|
||||
- mutable data may be signed by the key of the new owner, in which case mutable part itself must contain the binding. We could also use ring signature to sign the mutable data, concealing which owner signed the data - that would increase the signature size from 64 bytes to `32 * (n + 1)` bytes.
|
||||
|
||||
Current mutable data:
|
||||
|
||||
```haskell
|
||||
data UserLinkData = UserLinkData
|
||||
{ agentVRange :: VersionRangeSMPA,
|
||||
userData :: ConnInfo
|
||||
}
|
||||
```
|
||||
|
||||
Proposed mutable data:
|
||||
|
||||
```haskell
|
||||
data UserLinkData = UserLinkData
|
||||
{ agentVRange :: VersionRangeSMPA,
|
||||
owners :: [OwnerInfo]
|
||||
userData :: ConnInfo
|
||||
}
|
||||
|
||||
type OwnerId = ByteString
|
||||
|
||||
data OwnerInfo = OwnerInfo
|
||||
{ ownerId :: OwnerId, -- unique in the list, application specific - e.g., MemberId
|
||||
ownerKey :: PublicKeyEd25519,
|
||||
-- owner signature of sender ID,
|
||||
-- confirms that the owner agreed with being the owner,
|
||||
-- prevents a member being added as an owner without consent.
|
||||
ownerSig :: SignatureEd25519,
|
||||
-- owner authorization, sig(ownerId || ownerKey, prevKey), where prevKey is either a "genesis key" or some other key previously signed by the genesis key.
|
||||
authOwnerId :: OwnerId, -- null for "genesis"
|
||||
authOwnerSig :: SignatureEd25519
|
||||
}
|
||||
```
|
||||
|
||||
The size of the OwnerInfo record encoding is:
|
||||
- ownerId: 1 + 12
|
||||
- ownerKey: 1 + 32
|
||||
- ownerSig: 1 + 64
|
||||
- ownerAuthId: 1 + 12
|
||||
- ownerAuthSig: 1 + 64
|
||||
|
||||
~189 bytes, so we should practically limit the number of owners to say 8 - 1 original + 7 addiitonal. Original creator could use a different key as a "genesis" key, to conceal creator identity from other members, and it needs to include the record with memberId anyway.
|
||||
|
||||
The structure is simplified, and it does not allow arbitrary ownership changes. Its purpose is not to comprehensively manage ownership changes - while it is possible with a generic blockchain, it seems not appropriate at this stage, - but rather to ensure access continuity and that the server cannot modify the data (although nothing prevents the server from removing the data completely or from serving the previous version of the data).
|
||||
|
||||
For example it would only allow any given owner to remove subsequenty added owners, preserving the group link and identity, but it won't allow removing owners that signed this owner authorization. So owners are not equal, with the creator having the highest rank and being able to remove all additional owners, and owners authorise by creator can remove all other owners but themselves and creator, and so on - they have to maintain the chain that authorized themselves, at least. We could explicitely include owner rank into OwnerInfo, or we could require that they are sorted by rank, or the rank can be simply derived from signatures.
|
||||
|
||||
When additional owners want to be added to the group, they would have to provide any of the current owners:
|
||||
- the key for SMP commands authorization - this will be passed to SMP server together with other keys. There could be either RKEY to pass all keys (some risk to miss some, or of race conditions), or RADD/RGET/RDEL to add and remove recipient keys, which has no risk of race conditions.
|
||||
- the signature of the immutable data by their member key included in their profile.
|
||||
- the current owner would then include their member key into the queue data, and update it with LSET command. In any case there should be some simple consensus protocol between owners for owner changes, and it has to be maintained as a blockchain by owners and by chat relays, as otherwise it may lead to race conditions with LSET command.
|
||||
|
||||
Potentially, there could be one command to update keys and link data, so that they are consistent.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Service certificates for high volume servers and services connecting to SMP servers
|
||||
|
||||
## Problem
|
||||
|
||||
The absense of user and client identification benefits privacy, but it requires separately authorizing subscription for each messaging queue, that doesn't scale when a high volume server or service acts as a client for SMP server even for the current traffic and network size.
|
||||
|
||||
These servers/services include:
|
||||
- operators' chat relays (aka super-peers),
|
||||
- notification servers,
|
||||
- high-traffic service chat bots,
|
||||
- high-traffic business support clients.
|
||||
|
||||
The future chat relays would reduce the number of subscriptions required for the usual clients, by replacing connections with each group member to 1-3 connections with chat relays per group/community, it would shift the burden to the chat relays, that are also clients.
|
||||
|
||||
Self-hosted chat relays may want to retain privacy, so they will not use client certificates, but this privacy is not needed (and counter-productive) for the chat relays provided by network operators.
|
||||
|
||||
Even today, directory service subscribing to all queues may take 15-20 minutes, which is experienced as downtime by the end users.
|
||||
|
||||
Notification servers also acting as clients to messaging servers also take 15-20 minutes to subscribe to all notifications, during which time notifications are not delivered.
|
||||
|
||||
Not only these subscription take a lot of time, they also consume a large amount of memory both in the clients and in the servers, as association between clients and queues is currently session-scoped and not persisted anywhere (and it should not be, because end-users' clients do need privacy).
|
||||
|
||||
## Solution
|
||||
|
||||
High volume "clients" (operators' chat relays, directory service, SimpleX Chat team support client, SimpleX Status bot, etc.) that don't need privacy will identify themselves to the messaging servers at a point of connection by providing client sertificate, both in TLS handshake and in SMP handshake (the same certificate must be provided).
|
||||
|
||||
All the new queues and subscriptions made in this session will be creating a permanent association of the messaging queue with the client, and on subsequent reconnections the client can "subscribe" to all their queues with a single client subscription command.
|
||||
|
||||
This will save a lot of time subscribing and resubscribing on server and client restarts, servers' bandwidth, servers' traffic spikes, and memory of both clients and servers.
|
||||
|
||||
## Protocol
|
||||
|
||||
An ephemeral per-session signature key signed by long-term client certificate is used for client authorization – this session signature key will be passed in SMP handshake.
|
||||
|
||||
To transition existing queues, the subscription command will have to be double-signed - by the queue key, and then by client key.
|
||||
|
||||
When server receives such "hand-over" subscription it would create a permanent association between the client certificate and the queue, and on subsequent re-connections the client can subscribe to all the existing queues still associated with the client with one command.
|
||||
|
||||
The server will respond to the client with the number of queues it was subscribed to - it would both inform the client that it has to re-connect in case of interruption, and can be used for client and server statistics.
|
||||
|
||||
When client creates a new queue, it would also sign the request with both keys, per-queue and client's. Other queue operations (e.g., deletion, or changing associated queue data for short links) would still require two signatures, both the queue key and the client key.
|
||||
|
||||
The open question is whether there is any value in allowing to remove the association between the client and the queue. Probably not, as threat model should assume that the server would retain this information, and the use-case for users controlling their servers is narrow.
|
||||
|
||||
## Protocol connection handshake
|
||||
|
||||
Currently, the types for handshakes are:
|
||||
|
||||
```haskell
|
||||
data ServerHandshake = ServerHandshake
|
||||
{ smpVersionRange :: VersionRangeSMP,
|
||||
sessionId :: SessionId,
|
||||
-- pub key to agree shared secrets for command authorization and entity ID encryption.
|
||||
-- todo C.PublicKeyX25519
|
||||
authPubKey :: Maybe (X.CertificateChain, X.SignedExact X.PubKey)
|
||||
}
|
||||
|
||||
data ClientHandshake = ClientHandshake
|
||||
{ -- | agreed SMP server protocol version
|
||||
smpVersion :: VersionSMP,
|
||||
-- | server identity - CA certificate fingerprint
|
||||
keyHash :: C.KeyHash,
|
||||
-- | pub key to agree shared secret for entity ID encryption, shared secret for command authorization is agreed using per-queue keys.
|
||||
authPubKey :: Maybe C.PublicKeyX25519,
|
||||
-- | Whether connecting client is a proxy server (send from SMP v12).
|
||||
-- This property, if True, disables additional transport encrytion inside TLS.
|
||||
-- (Proxy server connection already has additional encryption, so this layer is not needed there).
|
||||
proxyServer :: Bool
|
||||
}
|
||||
```
|
||||
|
||||
`ServerHandshake` already contains `authPubKey` with the server certificate chain and the signed key for connection encryption and creating a shared secret for denable authorization (with client entity key) and session encryption layer.
|
||||
|
||||
`ClientHandshake` contains only ephemeral `authPubKey` to compute a shared secret for session encryption layer, so we need an additional field for an optional client certificate:
|
||||
|
||||
```haskell
|
||||
serviceCertKey :: Maybe (X.CertificateChain, X.SignedExact X.PubKey)
|
||||
```
|
||||
|
||||
Certificate here defines client identity. The actual key to be used to sign commands is session-scoped, and is signed by the certificate key. In case of notification server it MUST be the same certificate that is used for server TLS connections.
|
||||
|
||||
For operators' clients we may optionally include operators' certificate in the chain, and that would allow servers to identify operators if either wants to. This would improve end-user security, as not only the server would validate that its certificate matches the address, but it would also validate that it is operated by SimpleX Chat or by Flux, preventing any server impersonation (e.g., via DNS manipulations) - the client could then report that the files are hosted on SimpleX Chat servers, but then can stop and show additional warning in case certificate does not match the domain - same as the browsers do with CA stores in the client.
|
||||
|
||||
## Protocol transmissions
|
||||
|
||||
Each transport block can contain one or several protocol transmissions.
|
||||
|
||||
Each transmission has this structure:
|
||||
|
||||
```abnf
|
||||
transmission = authenticator authorized
|
||||
; authenticator - Ed25519 signature for recipients or X25519 authenticator for senders, to provide repudiation.
|
||||
; authenticator authorizes the rest of the transmission.
|
||||
authorized = sessId corrId entityId command.
|
||||
; sessId is tls-unique channel binding, its presense in the transmission prevents replay attacks.
|
||||
```
|
||||
|
||||
The proposed change would replace authenticator with exactly one or two authenticators, where the first one will remain resource-level authorization (queue key), and the optional second one will be client authorization with the client key.
|
||||
|
||||
```abnf
|
||||
authenticator = queue_authenticator ("0" / "1" service_authenticator)
|
||||
; "0" and "1" characters (digit characters, not x00 or x01) are conventionally used for Maybe types in the protocol.
|
||||
```
|
||||
|
||||
In case service_authenticator is present, queue_authenticator should authorize over `fingerprint authorized` (concatenation of service identity certificate fingerprint and the rest of the transmission).
|
||||
|
||||
All queues created with client key will have to be double-authorized with both the queue key and the client key - both the client and the server would have to maintain this knowledge, whether the queue is associated with the client or not.
|
||||
|
||||
Asymmetric retries have to be supported - the first request creating this association may succeed on the server and timeout on the client.
|
||||
|
||||
## Subscription
|
||||
|
||||
To subscribe to all associated queues the client has to send a single command authorized with the client key passed in handshake.
|
||||
|
||||
The command and response:
|
||||
|
||||
```haskell
|
||||
SUBS :: Command Recipient -- to enable all client subscriptions, empty entity ID in the transmission, signed by client key - it must be the same as was used in handover subscription signature.
|
||||
NSUBS :: Command Recipient -- notification subscription
|
||||
SOK :: Maybe ServiceId -- new subscription response
|
||||
SOKS :: Int64 -> BrokerMsg -- response from the server, includes the number of subscribed queues
|
||||
ENDS :: Int64 -> BrokerMsg -- when another session subscribes with the same certificate
|
||||
```
|
||||
|
||||
Open questions:
|
||||
- What should used as an entity ID for `SUBS` transmission - certificate fingerprint or an empty string?
|
||||
- Should there be a command to get the list of all associated queues? It is likely to be useful for debugging?
|
||||
- What should happen when `SUB` is sent for a single already associated queue? What if it is signed with the correct session key, but that is different from existing association? The current approach is that once associated, this associaiton would require authorization for single subscriptions, with the same certificate as already associated.
|
||||
|
||||
## Ephemeral client-session association
|
||||
|
||||
This was considered to reduce costs for the usual clients to re-subscribe. Currently it's a big problem, because of groups, and with transition to chat relays it won't be.
|
||||
|
||||
For some very busy end-user clients it may help.
|
||||
|
||||
Given that server has access to an ephemeral association between recipient client session and queues anyway (even with clients connecting via Tor, unless per-connection transport isolation is used), introducing `sessionPubKey` to allow resubscription to the previously subscribed queues may reduce the traffic. This won't change threat model as the server would only keep this association in memory, and not persist it. Clients on another hand may safely persist this association for fast resubscription on client restarts.
|
||||
|
||||
This is not planned for the forseable future, as migrating to chat relays would solve most of the problem.
|
||||
|
||||
Assuming an average active user has 20 contacts and 20 groups, and they would need ~3 subscriptions for each (for redundancy), so about 120 subscription to reconnect. The single 16kb transport block allows to send ~136 subscriptions. Which means that ephemeral sessions would create no value for clients at all, unless they are super active.
|
||||
|
||||
Further, improving transport efficiency for super-active non-identified clients may help network abuse, so ephemeral sessions may have negative value.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Using the same profile from multiple devices
|
||||
|
||||
## Problem
|
||||
|
||||
Double Ratchet algorithm makes it hard to send/receive messages sent to the user from different devices, as each message changes the state of Double Ratchet keys, and these state changes must be strictly sequential and they cannot be reversed (although skipping is possible).
|
||||
|
||||
Traditional approach for multi-device converts each direct conversation into a group, where each device participates as a member. Likewise, for group conversations each device also participates as a member. While these members *look* as if they are the same user to others, a very simple client app modification may show device ID for each message, and the communication peers, both in direct chats and in groups would know how many devices a user has and which device the user sent the message from. In addition to that, with this approach communication peers can send different messages to different devices (it can be prevented by provider who would request that only message key is encrypted with DR, while the encrypted message is the same) or withheld from some devices (it cannot be prevented by provider, as it cannot add key to the communication in case it is missing, and cannot withhold the message completely too). These opens various vectors for targeted attacks, e.g.:
|
||||
- tracking movements of the user: once each devices is identified as "desk" and "phone" it would allow to know where the user is at a given time.
|
||||
- manipulating information by sending messages to one device (to have proof it was sent) and withholding from others, or sending different messages if the protocol allows it.
|
||||
|
||||
In addition to that, the specific implementation of this approach in Signal compromises break-in recovery property (aka post-compromise security) of Double-Ratchet algorithm, making its design ineffective - the only reason to have the second ratchet in DR algorithm is to provide break-in recovery, without it a much simpler design with a single ratchet is sufficient. See [this paper](https://eprint.iacr.org/2021/626.pdf) for details.
|
||||
|
||||
While this limitation can be addressed with notifications when a new device is added and per-device keys, we still find the remaining attack vectors on user security and privacy to be unacceptable, and opening unsuspecting users to various criminal actions - and it is wrong to say that would only affect security conscious users, and most people would not be affected by these risks. Allowing potential criminals in groups to know which device you are currently using is a real risk for all users.
|
||||
|
||||
Another approach was offered by Threema that is ["mediator" server](https://threema.com/en/blog/md-architectural-overview) where the state of encryption ratchets is stored server-side. While it protects the user from their communication peers, it increases required level of trust to the servers, and in case of SimpleX network it would expose the knowledge of who communicates to whom. So while the idea of server-side storage of encryption state is promising, it has to be per-connection, to retain "no-accounts" property of SimpleX messaging network.
|
||||
|
||||
Also see [FAQ](https://simplex.chat/faq/#why-cant-i-use-the-same-profile-on-different-devices) and [this issue](https://github.com/simplex-chat/simplex-chat/issues/444#issuecomment-3066968358).
|
||||
|
||||
## Proposed solution
|
||||
|
||||
One of the ideas presented in FAQ - to store the state of Double Ratchet algorithm in the encrypted container on the server seems promising. The RFC develops this idea.
|
||||
|
||||
### Considerations for the design
|
||||
|
||||
1. The largest ratchet state size with the current implementation is less than 8kb (which is achieved when both sides shared PQ keys and ciphertexts), so while it cannot fit in the same transport blocks together with sent and received messages, it would fit in one transport block.
|
||||
|
||||
2. Protocol commands and events may be changed (even if at the cost of slightly reducing message size) can fit the hash of the ratchet state (32 bytes sha256 would be sufficient), so that the client can determine whether it has the most recent ratchet state or if it needs to retrieve the latest copy. Message size reduction won't affect the users because we use compression, and there is a substantial reserve.
|
||||
|
||||
3. Client commands that modify ratchet state would include the hash of the previous ratchet state so that the server can reject or ignore the command in case the previous ratchet state is different or in case command is repeated in case of lost response).
|
||||
|
||||
4. The client does not need to retrieve message state for each encryption and decryption operation - it can "speculatively" use the ratchet state it has, and receive correct ratchet state in the "error" response after attempting encryption based on incorrect ratchet state.
|
||||
|
||||
## Proposed protocol design
|
||||
|
||||
Ratchet state will be stored on the same server that stores message queue, as part of message queue record. 8kb is a sufficient size for this blob (the actual max size is 7800 bytes). The server would also store the hashes of the current and, possibly, the previous ratchet states (TBC).
|
||||
|
||||
While ratchet is used for duplex connection, the connection still has primary queue, and with redundancy the same ratchet state can be stored on all secondary queues.
|
||||
|
||||
Ratchet state will be encrypted using secret_box - a symmetric encryption scheme, so PQ-resistant. If ratchet state is stored on more than one server, it has to be encrypted with a different key for each server.
|
||||
|
||||
Questions: how to rotate the key used to store ratchet? Should key used to encrypt ratchet rotate at the same time when queue is rotated? The latter is a logical option, as it prevents additional complexity and solves the problem anyway. A possible option is to have "ratchet version" that will be used to advance the key used to encrypt ratchet via HKDF.
|
||||
|
||||
Security considerations: the scheme may reduce break-in recovery to the points queues are rotated, unless there is some randomness mixed-in into the key derivation (the key used to encrypt ratchet state). But including randomness would defeat the purpose, as other devices wouldn't be able to access the ratchets. Another approach would be to have each device use its own key for encryption, and encrypt to all keys of all devices (or to encrypt key, to avoid size increase). Having multiple encryptions would show how many devices use the queue, but servers already can observe it, so it is a better tradeoff. Another idea would be to rotate the key used to authorize queue commands - we already support multiple recipient keys, and it can be used for multi-device scenario. That would partially mitigate break-in attacks as the attacker who obtained the key from ratchet state would be able to decrypt it, but won't be able to decrypt it (the attacker collusion with the server is not mitigated). Yet another idea would be for each party (device) to share its private (or encapsulation) key and to have a symmetric key (used to encrypt the ratchet state) encrypted (encapsulated) separately for each device. This would reduce the size of the stored data to `ratchet size` + `encrypted key size` * N, so even in case of PQ encryption (e.g. sntrup) the size required to store the ratchet would be under transport block size, while limiting it to say 4-8 devices, which is sufficient.
|
||||
|
||||
To participate in multi-device scheme the devices would join the usual group that will be used to share public (encapsulation) device keys and to communicate updates to conversations that were received by the currently "active" device. "Active" means the device that received or sent and processed the message, and while only one device can receive messages from a given queue, device "active" state may be determined per queue, allowing concurrent usage.
|
||||
|
||||
The scheme must be resilient to state updates being lost, and in case of direct messages it would result in some messages not being shown (or shown as skipped), while conversation preference and profile updates can be re-requested from peers, while the current profile of the user would become the latest. Likewise, for groups state updates ca be requested from super-peers or for decentralized groups - from owners. Maintaining chat state consistency is an important consideration, but is not a focus of this RFC - the focus is managing message delivery and DR encryption for multiple devices. Other multi-device schemes have the same issues with state consistency. Partially, the profile state consistency can be improved by using a single shared queue (or set of queues) to store user's profile and chat preferences to synchronize profile updates asynchronously between the devices.
|
||||
|
||||
## The protocol to send the message
|
||||
|
||||
`rsi` - ratchet state on device `i`.
|
||||
|
||||
`enc(rs)` - current authoritative ratchet state on the server.
|
||||
|
||||
`pt` and `ct` - plaintext and ciphertext messages.
|
||||
|
||||
Encryption is a state transition function ratchetEnc: `(ct, rs') = ratchetEnc(pt, rs)`.
|
||||
|
||||
1. Device encrypts the message using the stored ratchet state: `(ct, rsi') = ratchetEnc(pt, rsi)`
|
||||
|
||||
2. Device sends modified encrypted ratchet state and the hash of the previous encrypted state to the server that stores the queue: `RSET (hash(enc(rsi)), enc(rsi'))`.
|
||||
|
||||
3. If the hash of the previous state matches state stored on the server (`hash(enc(rsi)) == hash(enc(rs))`), the server updates the state and responds with `ratchet_ok` (that may include the current state or it's hash, for validation). If the hash is different, the server responds with `bad_ratchet(enc(rs))` message that includes the correct ratchet state. These updates must be atomic. In this case device has to update the local ratchet state (provided it can decrypt it), and repeat encryption attempt. If device cannot decrypt the provided ratchet state, it means that the connection is disrupted (possibly, device is removed from device group, but missed the notifications).
|
||||
|
||||
4. After successful state update in primary receiving queue, the device would update it in secondary receiving queues.
|
||||
|
||||
5. Device sends encrypted message as usual, via proxy that must be different both from the server that stores the ratchet and from the destination server.
|
||||
|
||||
6. Device broadcasts sent message and new ratchet state to other devices in the device group.
|
||||
|
||||
This protocol is simple, and it minimizes requests when sending the message to one additional request to update ratchet state in most cases, only requiring two requests when device state was not updated via device group prior to message sending attempt.
|
||||
|
||||
## The protocol to receive the message
|
||||
|
||||
Decryption is also a state transition function: `(pt, rs') = ratchetDec(ct, rs)`
|
||||
|
||||
1. Server sends the message to the device (can be in response to SUB or ACK commands, or with active subscription). Pushed message would include the hash of the currently stored ratchet state: `hash(enc(rs))`.
|
||||
|
||||
2. If device has the ratchet state with the same hash (`hash(enc(rs)) == hash(enc(rsi))`), it decrypts the message: `(pt, rsi') = ratchetDec(ct, rsi)`.
|
||||
|
||||
3. If device has ratchet state with a different hash, it requests ratchet from the server with additional protocol command `RGET` with response `RCHT (enc(rs))` and updates the local state.
|
||||
|
||||
4. Device decrypts the message `(pt, rsi') = ratchetDec(ct, rsi)` and processes it as usual.
|
||||
|
||||
5. Device sends acknowledgement to the server as usual, but now it includes the new ratchet state and the hash of the previous state: `ACK msgId (hash(enc(rsi)), enc(rsi'))`
|
||||
|
||||
6. The server compares ratchet state with stored state hash, and in case it matches it processes `ACK` and responds with `OK` as usual (or `NO_MSG` in case msgId is incorrect, also as usual - it would happen in repeated ACK requests). If ratchet state hash does not match, the server would respond with `bad_ratchet(enc(rs))` - which means that the message was already processed by another device and ratchet was advanced. This is a complex scenario, as the client has to either revert the change from message processing or somehow combine the change with the updates communicated via device group (as a side note, device group can simply re-broadcast messages, not state updates, but it will result in state divergence between devices when different messages are lost).
|
||||
|
||||
Unlike sending messages, this flow does not require any additional requests in most cases, only requiring requesting message state reconciliation when the same message was received and processed by more than one client, but it does not require re-acknowledgement.
|
||||
|
||||
## Challenges
|
||||
|
||||
This is an idea of the design rather than the actual design, as it requires more thinking about:
|
||||
- how to handle concurrent ratchet state updates,
|
||||
- "active" status transitions per queue,
|
||||
- avoiding concurrent subscriptions to queues from multiple devices,
|
||||
- state updates and synchronization between devices,
|
||||
- handling skipped messages,
|
||||
- costs to update ratchets in bulk send scenario - this scheme would substantially increase costs of preparing large broadcasts, and it makes this scheme not acceptable for chat relays. Which means that "profile" on desktop used as chat relay won't be synched to other devices.
|
||||
- etc.
|
||||
|
||||
## Advantages
|
||||
|
||||
The communication peers won't know how many devices the user has, and which device was used to send the message. Also, the communication peers won't be able to send different messages to different user's devices, or to withhold messages from some devices.
|
||||
@@ -0,0 +1,154 @@
|
||||
# XFTP Server: SNI, CORS, and Web Support
|
||||
|
||||
Implementation details for Phase 3 of `rfcs/2026-01-30-send-file-page.md` (sections 6.1-6.4).
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The XFTP server is extended to support web browser clients by:
|
||||
|
||||
1. **SNI-based TLS certificate switching** — Present a CA-issued web certificate (e.g., Let's Encrypt) to browsers, while continuing to present the self-signed XFTP identity certificate to native clients.
|
||||
2. **CORS headers** — Add CORS response headers on SNI connections so browsers allow cross-origin XFTP requests.
|
||||
3. **Configuration** — `[WEB]` INI section for HTTPS cert/key paths; opt-in (commented out by default).
|
||||
|
||||
Web handshake (challenge-response identity proof, §6.3 of parent RFC) is not yet implemented and will be added separately.
|
||||
|
||||
## 2. SNI Certificate Switching
|
||||
|
||||
### 2.1 Reusing the SMP Pattern
|
||||
|
||||
The SMP server already implements SNI-based certificate switching via `TLSServerCredential` and `runTransportServerState_` (see `rfcs/2024-09-15-shared-port.md`). The XFTP server applies the same pattern with one key difference: both native and web XFTP clients use HTTP/2 transport, whereas SMP switches between raw SMP protocol and HTTP entirely.
|
||||
|
||||
### 2.2 Approach
|
||||
|
||||
When `httpServerCreds` is configured, the XFTP server bypasses `runHTTP2Server` and uses `runTransportServerState_` directly to obtain the per-connection `sniUsed` flag. It then sets up HTTP/2 manually on each TLS connection using `withHTTP2` (same internals as `runHTTP2ServerWith_`). The `sniUsed` flag is captured in the closure and shared by all HTTP/2 requests on that connection.
|
||||
|
||||
When `httpServerCreds` is absent, the existing `runHTTP2Server` path is unchanged.
|
||||
|
||||
```
|
||||
Native client (no SNI) ──TLS──> XFTP identity cert ──HTTP/2──> processRequest (no CORS)
|
||||
Browser client (SNI) ──TLS──> Web CA cert ──HTTP/2──> processRequest (+ CORS)
|
||||
```
|
||||
|
||||
### 2.3 Certificate Chain
|
||||
|
||||
The web certificate file (e.g., `web.crt`) must contain the full chain: leaf certificate followed by the signing CA certificate. `loadServerCredential` uses `T.credentialLoadX509Chain` which reads all PEM blocks from the file.
|
||||
|
||||
The client validates the chain by comparing `idCert` fingerprint (the CA cert, second in the 2-cert chain) against the known `keyHash`. This is the same validation as for XFTP identity certificates — the CA that signed the web cert must match the XFTP server's identity.
|
||||
|
||||
## 3. CORS Support
|
||||
|
||||
### 3.1 Design
|
||||
|
||||
CORS headers are only added when both conditions are true:
|
||||
- `addCORSHeaders` is `True` in `TransportServerConfig` (set in XFTP `Main.hs`)
|
||||
- `sniUsed` is `True` for the current TLS connection
|
||||
|
||||
This ensures native clients never see CORS headers.
|
||||
|
||||
### 3.2 Response Headers
|
||||
|
||||
All POST responses on SNI connections include:
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Expose-Headers: *
|
||||
```
|
||||
|
||||
### 3.3 OPTIONS Preflight
|
||||
|
||||
OPTIONS requests are intercepted at the HTTP/2 dispatch level, before `processRequest`. This is necessary because `processRequest` rejects bodies that don't match `xftpBlockSize`.
|
||||
|
||||
Preflight response:
|
||||
```
|
||||
HTTP/2 200
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: POST, OPTIONS
|
||||
Access-Control-Allow-Headers: *
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
### 3.4 Security
|
||||
|
||||
`Access-Control-Allow-Origin: *` is safe because:
|
||||
- All XFTP commands require Ed25519 authentication (per-chunk keys from file description).
|
||||
- No cookies or browser credentials are involved.
|
||||
- File content is end-to-end encrypted.
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
### 4.1 INI Template
|
||||
|
||||
```ini
|
||||
[WEB]
|
||||
# cert: /etc/opt/simplex-xftp/web.crt
|
||||
# key: /etc/opt/simplex-xftp/web.key
|
||||
```
|
||||
|
||||
Commented out by default — web support is opt-in.
|
||||
|
||||
### 4.2 Behavior
|
||||
|
||||
- `[WEB]` section not configured: silently ignored, server operates normally for native clients only.
|
||||
- `[WEB]` section configured with valid cert/key paths: SNI + CORS enabled.
|
||||
- `[WEB]` section configured with missing cert files: warning + continue (non-fatal, unlike SMP where it is fatal).
|
||||
|
||||
## 5. Files Modified
|
||||
|
||||
### 5.1 `src/Simplex/Messaging/Transport/Server.hs`
|
||||
|
||||
Added `addCORSHeaders :: Bool` field to `TransportServerConfig`. Updated `mkTransportServerConfig` to accept the new parameter. All existing SMP call sites pass `False`.
|
||||
|
||||
### 5.2 `src/Simplex/Messaging/Transport/HTTP2/Server.hs`
|
||||
|
||||
- Extracted `expireInactiveClient` from `runHTTP2ServerWith_`'s `where` clause to a module-level function.
|
||||
- Parameterized `runHTTP2ServerWith_`: setup type changed from `((TLS p -> IO ()) -> a)` to `(((Bool, TLS p) -> IO ()) -> a)`, callback from `HTTP2ServerFunc` to `Bool -> HTTP2ServerFunc`. The `Bool` is the per-connection `sniUsed` flag, threaded through `H.run` to the callback.
|
||||
- Extended `runHTTP2Server` with `Maybe T.Credential` parameter for SNI web certificate. Its setup uses `runTransportServerState_` with `TLSServerCredential`, which naturally provides `(sniUsed, tls)` pairs matching the new `runHTTP2ServerWith_` setup type.
|
||||
- Adapted `runHTTP2ServerWith` (client-side HTTP/2, no SNI): wraps its setup to inject `(False, tls)` and its callback with `const`.
|
||||
- Updated `getHTTP2Server` (test helper) to pass `Nothing` for httpCreds.
|
||||
|
||||
### 5.3 `src/Simplex/FileTransfer/Server/Env.hs`
|
||||
|
||||
- Added `httpCredentials :: Maybe ServerCredentials` to `XFTPServerConfig`.
|
||||
- Added `httpServerCreds :: Maybe T.Credential` to `XFTPEnv`.
|
||||
- `newXFTPServerEnv` loads HTTP credentials when configured.
|
||||
|
||||
### 5.4 `src/Simplex/FileTransfer/Server/Main.hs`
|
||||
|
||||
- Added `[WEB]` section to INI template.
|
||||
- Added `httpCredentials` parsing from INI `[WEB]` section (`cert` and `key` fields).
|
||||
- Set `addCORSHeaders = isJust httpCredentials_` in transport config (conditional on web cert presence).
|
||||
|
||||
### 5.5 `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
Core server changes:
|
||||
|
||||
- `runServer` calls `runHTTP2Server` with `httpCreds_` and a `\sniUsed -> handleRequest (sniUsed && addCORSHeaders transportConfig)` callback. TLS params are `defaultSupportedParamsHTTPS` when web creds present, `defaultSupportedParams` otherwise. SNI routing, HTTP/2 setup, and client expiration are handled inside `runHTTP2Server`.
|
||||
|
||||
- `XFTPTransportRequest` carries `addCORS :: Bool` field, threaded through to `sendXFTPResponse`.
|
||||
|
||||
- `sendXFTPResponse` conditionally includes CORS headers based on `addCORS`.
|
||||
|
||||
- OPTIONS requests on SNI connections return CORS preflight headers before reaching `processRequest`.
|
||||
|
||||
- Helper functions: `corsHeaders` (response headers), `corsPreflightHeaders` (preflight headers).
|
||||
|
||||
### 5.6 `tests/XFTPClient.hs`
|
||||
|
||||
- Added `httpCredentials = Nothing` to `testXFTPServerConfig`.
|
||||
- Added `testXFTPServerConfigSNI` with web cert config and `addCORSHeaders = True`.
|
||||
- Added `withXFTPServerSNI` helper.
|
||||
|
||||
### 5.7 `tests/XFTPServerTests.hs`
|
||||
|
||||
Added SNI and CORS tests as a subsection within `xftpServerTests` (6 tests):
|
||||
|
||||
1. **SNI cert selection** — Connect with SNI + `h2` ALPN, verify RSA web certificate is presented.
|
||||
2. **Non-SNI cert selection** — Connect without SNI + `xftp/1` ALPN, verify Ed448 XFTP certificate is presented.
|
||||
3. **CORS headers** — SNI POST request includes `Access-Control-Allow-Origin: *` and `Access-Control-Expose-Headers: *`.
|
||||
4. **OPTIONS preflight** — SNI OPTIONS request returns all CORS preflight headers.
|
||||
5. **No CORS without SNI** — Non-SNI POST request has no CORS headers.
|
||||
6. **File chunk delivery** — Full XFTP file chunk upload/download through SNI-enabled server verifying no regression.
|
||||
|
||||
## 6. Remaining Work
|
||||
|
||||
- **Web handshake** (§6.3 of parent RFC): Challenge-response identity proof for SNI connections. The server detects web clients via the `sniUsed` flag and expects a 32-byte challenge in the first POST body (non-empty, unlike standard handshake). Response includes full cert chain + signature over `(challenge ++ sessionId)`.
|
||||
- **Static page serving** (§6.5 of parent RFC): Optional serving of the web page HTML/JS bundle on GET requests.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Web Handshake — Challenge-Response Identity Proof
|
||||
|
||||
RFC §6.3: Server proves XFTP identity to web clients independently of TLS CA infrastructure.
|
||||
|
||||
## 1. Protocol
|
||||
|
||||
**Standard handshake** (unchanged):
|
||||
```
|
||||
Client → empty POST → Server
|
||||
Server → padded {vRange, sessionId, authPubKey, Nothing} → Client
|
||||
Client → padded {version, keyHash, Nothing} → Server
|
||||
Server → empty → Client
|
||||
```
|
||||
|
||||
**Web handshake** (SNI connection, non-empty hello):
|
||||
```
|
||||
Client → padded {32 random bytes} → Server
|
||||
Server → padded {vRange, sessionId, authPubKey, Just sigBytes} → Client
|
||||
sigBytes = signatureBytes(sign(identityLeafKey, challenge <> sessionId))
|
||||
Client validates:
|
||||
1. chainIdCaCerts(authPubKey.certChain) → CCValid {leafCert, idCert}
|
||||
2. SHA-256(idCert) == keyHash (server identity)
|
||||
3. verify(leafCert.pubKey, sigBytes, challenge <> sessionId) (challenge-response)
|
||||
4. verify(leafCert.pubKey, signedPubKey.signature, signedPubKey.objectDer) (DH key auth)
|
||||
Client → padded {version, keyHash, Just challenge} → Server
|
||||
Server verifies: echoed challenge == stored challenge from step 1
|
||||
Server → empty → Client
|
||||
```
|
||||
|
||||
**Detection**: `sniUsed` per-connection flag. Non-empty hello allowed only when `sniUsed`. Empty hello with SNI → standard handshake.
|
||||
|
||||
**Why both steps 3 and 4**: Native clients verify `signedPubKey` using the TLS peer certificate (`serverKey` from `getServerVerifyKey`), which is the XFTP identity cert in non-SNI connections — TLS provides this binding. Web clients cannot access TLS peer certificate data (browser API limitation; TLS presents the web CA cert but provides no API to extract it). So web clients must verify at the application layer using `authPubKey.certChain`, which always contains the XFTP identity chain regardless of which cert TLS used. Step 3 proves the server holds its identity key *right now* (freshness via random challenge). Step 4 proves the DH session key was signed by the identity key holder (prevents MITM key substitution). Together they give web clients some assurance native clients get from TLS, except channel binding for commands.
|
||||
|
||||
## 2. Type Changes — `src/Simplex/FileTransfer/Transport.hs`
|
||||
|
||||
### `XFTPServerHandshake` (line 114)
|
||||
|
||||
Add field: `webIdentityProof :: Maybe ByteString` — raw Ed448 signature bytes (114 bytes), or `Nothing` for standard handshake. No record needed — the cert chain is already in `authPubKey.certChain`.
|
||||
|
||||
### `Encoding XFTPServerHandshake` (line 136)
|
||||
|
||||
- `smpEncode`: append `smpEncode webIdentityProof`
|
||||
- `smpP`: `Tail compat`, if non-empty `eitherToMaybe $ smpDecode compat`
|
||||
|
||||
Backward compat: old clients ignore via `Tail _compat`; new client + old server → empty compat → `Nothing`.
|
||||
|
||||
### `XFTPClientHandshake` (line 121)
|
||||
|
||||
Add field: `webChallenge :: Maybe ByteString`
|
||||
|
||||
### `Encoding XFTPClientHandshake` (line 128)
|
||||
|
||||
Same `Tail compat` pattern as server handshake.
|
||||
|
||||
### Export list
|
||||
|
||||
Both types use `(..)` export — new fields auto-exported.
|
||||
|
||||
## 3. Server Changes — `src/Simplex/FileTransfer/Server.hs`
|
||||
|
||||
### `XFTPTransportRequest` (line 88)
|
||||
|
||||
Add field: `sniUsed :: SNICredentialUsed` (`Bool` from `Transport.Server`). Add import.
|
||||
|
||||
### `Handshake` (line 117)
|
||||
|
||||
`HandshakeSent C.PrivateKeyX25519` → `HandshakeSent C.PrivateKeyX25519 (Maybe ByteString)` — stores 32-byte web challenge or `Nothing`.
|
||||
|
||||
### `runServer` handler (line 145–161)
|
||||
|
||||
- Pass `sniUsed` into request construction (line 154)
|
||||
- SNI-first routing: when `sniUsed`, always route to `xftpServerHandshakeV1` (web ALPN `h2` would otherwise fall to `_` catch-all)
|
||||
|
||||
### `xftpServerHandshakeV1` (line 162)
|
||||
|
||||
- Destructure `sniUsed` from request
|
||||
- Match `HandshakeSent pk challenge_` → `processClientHandshake pk challenge_`
|
||||
|
||||
### `processHello` (line 171)
|
||||
|
||||
- Branch `(sniUsed, B.null bodyHead)`:
|
||||
- `(_, True)` → standard: `challenge_ = Nothing`
|
||||
- `(True, False)` → web: unpad, verify 32 bytes, `challenge_ = Just`
|
||||
- `(False, False)` → `throwE HANDSHAKE`
|
||||
- Store: `HandshakeSent pk challenge_`
|
||||
- Compute: `webIdentityProof = C.signatureBytes . C.sign serverSignKey . (<> sessionId) <$> challenge_`
|
||||
- Construct `XFTPServerHandshake` with `webIdentityProof`
|
||||
|
||||
### `processClientHandshake` (line 183)
|
||||
|
||||
- Accept `challenge_` parameter
|
||||
- Decode `webChallenge` from `XFTPClientHandshake`
|
||||
- Add: `unless (challenge_ == webChallenge) $ throwE HANDSHAKE`
|
||||
(standard: both `Nothing` → passes)
|
||||
|
||||
## 4. Native Client — `src/Simplex/FileTransfer/Client.hs`
|
||||
|
||||
### `xftpClientHandshakeV1` (line 142)
|
||||
|
||||
Add `webChallenge = Nothing` in `sendClientHandshake` call.
|
||||
|
||||
No other changes — parser handles new fields via `Tail`, native client ignores `webIdentityProof`.
|
||||
|
||||
## 5. TypeScript Changes (DONE except Ed448)
|
||||
|
||||
Sections 5.1 and 5.2 are implemented. Section 5.3 needs Ed448 support.
|
||||
|
||||
## 10. Ed448 Support via `@noble/curves`
|
||||
|
||||
**Problem**: Production servers use Ed448 certificates (default). `identity.ts` only supports Ed25519 via libsodium. libsodium has no Ed448 support and never will.
|
||||
|
||||
**Solution**: Add `@noble/curves` dependency for Ed448 verification only. All other crypto stays with libsodium.
|
||||
|
||||
### 10.1 `xftp-web/package.json` — Add dependency
|
||||
|
||||
```json
|
||||
"dependencies": {
|
||||
"libsodium-wrappers-sumo": "^0.7.13",
|
||||
"@noble/curves": "^1.9.7"
|
||||
}
|
||||
```
|
||||
|
||||
Use v1.x (supports both CJS and ESM). v2.x is ESM-only with `.js` extension requirement.
|
||||
|
||||
### 10.2 `xftp-web/src/crypto/keys.ts` — Ed448 DER constants and decode
|
||||
|
||||
Add Ed448 SPKI DER prefix (12 bytes, same prefix length as Ed25519):
|
||||
```
|
||||
30 43 30 05 06 03 2b 65 71 03 3a 00
|
||||
```
|
||||
|
||||
| Property | Ed25519 | Ed448 |
|
||||
|----------|---------|-------|
|
||||
| OID | `2b 65 70` | `2b 65 71` |
|
||||
| SPKI prefix | `30 2a ...` | `30 43 ...` |
|
||||
| Raw key size | 32 bytes | 57 bytes |
|
||||
| SPKI total | 44 bytes | 69 bytes |
|
||||
| Signature size | 64 bytes | 114 bytes |
|
||||
|
||||
New functions:
|
||||
- `decodePubKeyEd448(der: Uint8Array): Uint8Array` — 69 bytes → 57 bytes raw
|
||||
- `encodePubKeyEd448(raw: Uint8Array): Uint8Array` — 57 bytes → 69 bytes DER
|
||||
- `verifyEd448(publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean` — uses `ed448.verify(sig, msg, publicKey)` from `@noble/curves/ed448`
|
||||
|
||||
Note: `@noble/curves` parameter order is `(signature, message, publicKey)`, not `(publicKey, signature, message)`.
|
||||
|
||||
### 10.3 `xftp-web/src/crypto/identity.ts` — Algorithm-agnostic verification
|
||||
|
||||
Replace `extractCertEd25519Key` + hardcoded Ed25519 `verify` with algorithm detection:
|
||||
|
||||
1. `extractCertPublicKeyInfo(certDer)` → SPKI DER (already exists, works for any algorithm)
|
||||
2. Detect algorithm from SPKI: byte at offset 8 is `0x70` (Ed25519) or `0x71` (Ed448)
|
||||
3. Extract raw key with appropriate decoder
|
||||
4. Verify signatures with appropriate function
|
||||
|
||||
```typescript
|
||||
type CertKeyAlgorithm = 'ed25519' | 'ed448'
|
||||
|
||||
function detectKeyAlgorithm(spki: Uint8Array): CertKeyAlgorithm {
|
||||
if (spki.length === 44 && spki[8] === 0x70) return 'ed25519'
|
||||
if (spki.length === 69 && spki[8] === 0x71) return 'ed448'
|
||||
throw new Error("unsupported certificate key algorithm")
|
||||
}
|
||||
```
|
||||
|
||||
`verifyIdentityProof` changes:
|
||||
- Extract SPKI from leaf cert
|
||||
- Detect algorithm → choose `decodePubKeyEd25519`/`decodePubKeyEd448` and `verify`/`verifyEd448`
|
||||
- Both challenge signature and DH key signature use the same leaf key + algorithm
|
||||
|
||||
Remove `extractCertEd25519Key` (replaced by generic path). Keep `extractCertPublicKeyInfo` (already generic).
|
||||
|
||||
### 10.4 `xftp-web/src/protocol/handshake.ts` — Comment update
|
||||
|
||||
`SignedKey.signature` comment: "raw Ed25519 signature bytes (64 bytes)" → "raw signature bytes (Ed25519: 64, Ed448: 114)"
|
||||
|
||||
### 10.5 Tests — `tests/XFTPWebTests.hs`
|
||||
|
||||
**Integration test**: Switch from `withXFTPServerEd25519SNI` (Ed25519 fixtures) to `withXFTPServerSNI` (default Ed448 fixtures). Update fingerprint source from `tests/fixtures/ed25519/ca.crt` to `tests/fixtures/ca.crt`.
|
||||
|
||||
Optionally add a second integration test with Ed25519 to cover both paths, or rely on existing unit tests for Ed25519 coverage.
|
||||
|
||||
### 10.6 Implementation order
|
||||
|
||||
1. `npm install @noble/curves` in `xftp-web/`
|
||||
2. `keys.ts` — Ed448 constants, decode, encode, verifyEd448
|
||||
3. `identity.ts` — algorithm detection, generic verification
|
||||
4. `handshake.ts` — comment fix
|
||||
5. `XFTPWebTests.hs` — switch integration test to Ed448
|
||||
6. Build TS + run all tests
|
||||
|
||||
## 6. Haskell Integration Test — `tests/XFTPServerTests.hs`
|
||||
|
||||
Add `testWebHandshake` to "XFTP SNI and CORS" describe block.
|
||||
|
||||
1. `withXFTPServerSNI` — server with web credentials
|
||||
2. Connect with SNI + `h2` ALPN
|
||||
3. Send padded 32-byte challenge
|
||||
4. Decode `XFTPServerHandshake`, assert `webIdentityProof` is `Just`
|
||||
5. `chainIdCaCerts` on `authPubKey.certChain` → `CCValid {leafCert, idCert}`
|
||||
6. Verify `SHA-256(idCert) == keyHash`
|
||||
7. Extract `leafCert` public key, verify challenge signature
|
||||
8. Verify `signedPubKey` signature using `leafCert` key (DH key auth)
|
||||
9. Send `XFTPClientHandshake` with `webChallenge = Just challenge`
|
||||
10. Assert empty response
|
||||
|
||||
Imports: `XFTPServerHandshake (..)`, `XFTPClientHandshake (..)`, `ChainCertificates (..)`, `chainIdCaCerts`.
|
||||
|
||||
## 7. TS Tests — `tests/XFTPWebTests.hs`
|
||||
|
||||
### Unit tests
|
||||
|
||||
- **`decodeServerHandshake` with proof**: Haskell-encode with `Just sigBytes`, TS-decode, verify bytes match.
|
||||
- **`encodeClientHandshake` with challenge**: TS-encode, compare with Haskell-encoded.
|
||||
- **`chainIdCaCerts`**: 2/3/4-cert chains return correct positions.
|
||||
- **`caFingerprint` (fixed)**: matches `sha256(idCert)` for 2 and 3-cert chains.
|
||||
|
||||
### Integration test
|
||||
|
||||
Node.js inline script against `withXFTPServerSNI`:
|
||||
1. Connect with SNI via `http2.connect`
|
||||
2. Send padded challenge, decode `XFTPServerHandshake` with TS
|
||||
3. `verifyIdentityProof` — full chain validation + challenge sig + DH key sig
|
||||
4. Send client handshake with echoed challenge
|
||||
5. Assert empty response
|
||||
|
||||
## 8. Implementation Order
|
||||
|
||||
1. `Transport.hs` — `Maybe` fields + encoding instances
|
||||
2. `Server.hs` — `sniUsed`, challenge in `Handshake`, `processHello`, `processClientHandshake`, SNI routing
|
||||
3. `Client.hs` — `webChallenge = Nothing`
|
||||
4. Build: `cabal build --ghc-options -O0`
|
||||
5. Run existing SNI/CORS tests
|
||||
6. `XFTPServerTests.hs` — `testWebHandshake`
|
||||
7. `handshake.ts` — types, decoding, `chainIdCaCerts`, fix `caFingerprint`
|
||||
8. `crypto/identity.ts` — Node.js verification functions
|
||||
9. `XFTPWebTests.hs` — unit + integration tests
|
||||
10. Build TS + run all tests
|
||||
|
||||
## 9. Verification
|
||||
|
||||
```bash
|
||||
cd xftp-web && npm install && npm run build && cd ..
|
||||
cabal test --ghc-options=-O0 --test-option='--match=/XFTP/XFTP server/XFTP SNI and CORS/' --test-show-details=streaming
|
||||
cabal test --ghc-options=-O0 --test-option='--match=/XFTP Web Client/' --test-show-details=streaming
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
# Plan: Browser ↔ Haskell File Transfer Tests
|
||||
|
||||
## Table of Contents
|
||||
1. Goal
|
||||
2. Current State
|
||||
3. Implementation
|
||||
4. Success Criteria
|
||||
5. Files
|
||||
6. Order
|
||||
|
||||
## 1. Goal
|
||||
Run browser upload/download tests in headless Chromium via Vitest, proving fetch-based transport works in real browser environment.
|
||||
|
||||
## 2. Current State
|
||||
- `client.ts`: Transport abstraction done — http2 for Node, fetch for browser ✓
|
||||
- `agent.ts`: Uses `node:crypto` (randomBytes) and `node:zlib` (deflateRawSync/inflateRawSync) — **won't run in browser**
|
||||
- `XFTPWebTests.hs`: Cross-language tests exist (Haskell calls TS via Node.js) ✓
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
### 3.1 Make agent.ts isomorphic
|
||||
|
||||
| Current (Node.js only) | Isomorphic replacement |
|
||||
|------------------------|------------------------|
|
||||
| `import crypto from "node:crypto"` | Remove import |
|
||||
| `import zlib from "node:zlib"` | `import pako from "pako"` |
|
||||
| `crypto.randomBytes(32)` | `crypto.getRandomValues(new Uint8Array(32))` |
|
||||
| `zlib.deflateRawSync(buf)` | `pako.deflateRaw(buf)` |
|
||||
| `zlib.inflateRawSync(buf)` | `pako.inflateRaw(buf)` |
|
||||
|
||||
Note: `crypto.getRandomValues` available in both browser and Node.js (globalThis.crypto).
|
||||
|
||||
### 3.2 Vitest browser mode setup
|
||||
|
||||
`package.json` additions:
|
||||
```json
|
||||
"devDependencies": {
|
||||
"vitest": "^3.0.0",
|
||||
"@vitest/browser": "^3.0.0",
|
||||
"playwright": "^1.50.0",
|
||||
"@types/pako": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
`vitest.config.ts`:
|
||||
```typescript
|
||||
import {defineConfig} from 'vitest/config'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
|
||||
// Compute fingerprint from ca.crt (same as Haskell's loadFileFingerprint)
|
||||
const caCert = readFileSync('../tests/fixtures/ca.crt')
|
||||
const fingerprint = createHash('sha256').update(caCert).digest('base64url')
|
||||
const serverAddr = `xftp://${fingerprint}@localhost:7000`
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr)
|
||||
},
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{browser: 'chromium'}],
|
||||
headless: true,
|
||||
providerOptions: {
|
||||
launch: {ignoreHTTPSErrors: true}
|
||||
}
|
||||
},
|
||||
globalSetup: './test/globalSetup.ts'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 Server startup
|
||||
|
||||
`test/globalSetup.ts`:
|
||||
```typescript
|
||||
import {spawn, ChildProcess} from 'child_process'
|
||||
import {resolve, join} from 'path'
|
||||
import {mkdtempSync, writeFileSync, copyFileSync} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
|
||||
let server: ChildProcess | null = null
|
||||
|
||||
export async function setup() {
|
||||
const fixtures = resolve(__dirname, '../../tests/fixtures')
|
||||
|
||||
// Create temp directories
|
||||
const cfgDir = mkdtempSync(join(tmpdir(), 'xftp-cfg-'))
|
||||
const logDir = mkdtempSync(join(tmpdir(), 'xftp-log-'))
|
||||
const filesDir = mkdtempSync(join(tmpdir(), 'xftp-files-'))
|
||||
|
||||
// Copy certificates to cfgDir (xftp-server expects ca.crt, server.key, server.crt there)
|
||||
copyFileSync(join(fixtures, 'ca.crt'), join(cfgDir, 'ca.crt'))
|
||||
copyFileSync(join(fixtures, 'server.key'), join(cfgDir, 'server.key'))
|
||||
copyFileSync(join(fixtures, 'server.crt'), join(cfgDir, 'server.crt'))
|
||||
|
||||
// Write INI config file
|
||||
const iniContent = `[STORE_LOG]
|
||||
enable: off
|
||||
|
||||
[TRANSPORT]
|
||||
host: localhost
|
||||
port: 7000
|
||||
|
||||
[FILES]
|
||||
path: ${filesDir}
|
||||
|
||||
[WEB]
|
||||
cert: ${join(fixtures, 'web.crt')}
|
||||
key: ${join(fixtures, 'web.key')}
|
||||
`
|
||||
writeFileSync(join(cfgDir, 'file-server.ini'), iniContent)
|
||||
|
||||
// Spawn xftp-server with env vars
|
||||
server = spawn('cabal', ['exec', 'xftp-server', '--', 'start'], {
|
||||
env: {
|
||||
...process.env,
|
||||
XFTP_SERVER_CFG_PATH: cfgDir,
|
||||
XFTP_SERVER_LOG_PATH: logDir
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
// Wait for "Listening on port 7000..."
|
||||
await waitForServerReady(server)
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
server?.kill('SIGTERM')
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
function waitForServerReady(proc: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Server start timeout')), 15000)
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
if (data.toString().includes('Listening on port')) {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[xftp-server]', data.toString())
|
||||
})
|
||||
proc.on('error', reject)
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code !== 0) reject(new Error(`Server exited with code ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Server env vars (from `apps/xftp-server/Main.hs` + `getEnvPath`):
|
||||
- `XFTP_SERVER_CFG_PATH` — directory containing `file-server.ini` and certs (`ca.crt`, `server.key`, `server.crt`)
|
||||
- `XFTP_SERVER_LOG_PATH` — directory for logs
|
||||
|
||||
### 3.4 Browser test
|
||||
|
||||
`test/browser.test.ts`:
|
||||
```typescript
|
||||
import {test, expect} from 'vitest'
|
||||
import {encryptFileForUpload, uploadFile, downloadFile} from '../src/agent.js'
|
||||
import {parseXFTPServer} from '../src/protocol/address.js'
|
||||
|
||||
const server = parseXFTPServer(import.meta.env.XFTP_SERVER)
|
||||
|
||||
test('browser upload + download round-trip', async () => {
|
||||
const data = new Uint8Array(50000)
|
||||
crypto.getRandomValues(data)
|
||||
const encrypted = encryptFileForUpload(data, 'test.bin')
|
||||
const {rcvDescription} = await uploadFile(server, encrypted)
|
||||
const {content} = await downloadFile(rcvDescription)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
```
|
||||
|
||||
## 4. Success Criteria
|
||||
|
||||
1. `npm run build` — agent.ts compiles without node: imports
|
||||
2. `cabal test --test-option='--match=/XFTP Web Client/'` — existing Node.js tests still pass
|
||||
3. `npm run test:browser` — browser round-trip test passes in headless Chromium
|
||||
|
||||
## 5. Files to Create/Modify
|
||||
|
||||
**Modify:**
|
||||
- `xftp-web/package.json` — add vitest, @vitest/browser, playwright, pako, @types/pako
|
||||
- `xftp-web/src/agent.ts` — replace node:crypto, node:zlib with isomorphic alternatives
|
||||
|
||||
**Create:**
|
||||
- `xftp-web/vitest.config.ts` — browser mode config
|
||||
- `xftp-web/test/globalSetup.ts` — xftp-server lifecycle
|
||||
- `xftp-web/test/browser.test.ts` — browser round-trip test
|
||||
|
||||
## 6. Order of Implementation
|
||||
|
||||
1. **Add pako dependency** — `npm install pako @types/pako`
|
||||
2. **Make agent.ts isomorphic** — replace node:crypto, node:zlib
|
||||
3. **Verify Node.js tests pass** — `cabal test --test-option='--match=/XFTP Web Client/'`
|
||||
4. **Set up Vitest** — add devDeps, create vitest.config.ts
|
||||
5. **Create globalSetup.ts** — write INI config, spawn xftp-server
|
||||
6. **Write browser test** — upload + download round-trip
|
||||
7. **Verify browser test passes** — `npm run test:browser`
|
||||
@@ -0,0 +1,920 @@
|
||||
# Browser Transport & Web Worker Architecture
|
||||
|
||||
## TOC
|
||||
|
||||
1. Executive Summary
|
||||
2. Transport: fetch() API
|
||||
3. Architecture: Environment Abstraction
|
||||
4. Web Worker Implementation
|
||||
5. OPFS Implementation
|
||||
6. Implementation Plan
|
||||
7. Testing Strategy
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Adapt `client.ts` from `node:http2` to `fetch()` API for isomorphic Node.js/browser support. Add environment abstraction layer so the same upload/download pipeline works with or without Web Workers and with or without OPFS. In browsers, crypto runs in a Web Worker to keep UI responsive; in Node.js tests, crypto runs directly.
|
||||
|
||||
**Key architectural constraint:** Existing crypto functions (`encryptFile`, `decryptChunks`, etc.) remain unchanged. The abstraction layer wraps them, choosing execution context (direct vs Worker) and storage (memory vs OPFS) based on environment.
|
||||
|
||||
**Scope:**
|
||||
- Replace `node:http2` with `fetch()` in `client.ts`
|
||||
- Add `CryptoBackend` abstraction with three implementations
|
||||
- Create Web Worker that calls existing crypto functions
|
||||
- Add OPFS storage for large files in browser
|
||||
|
||||
**Out of scope:** Web page UI (Phase 5 in main RFC).
|
||||
|
||||
## 2. Transport: fetch() API
|
||||
|
||||
### 2.1 Current State
|
||||
|
||||
`client.ts` uses `node:http2`:
|
||||
```typescript
|
||||
import http2 from "node:http2"
|
||||
const session = http2.connect(url)
|
||||
const stream = session.request({':method': 'POST', ':path': '/'})
|
||||
stream.write(commandBlock)
|
||||
stream.end(chunkData)
|
||||
```
|
||||
|
||||
### 2.2 Target State
|
||||
|
||||
Isomorphic `fetch()` (Node.js 18+ and browsers):
|
||||
```typescript
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: concatStreams(commandBlock, chunkData),
|
||||
duplex: 'half', // Required for streaming request body
|
||||
})
|
||||
const reader = response.body!.getReader()
|
||||
```
|
||||
|
||||
### 2.3 Key Differences
|
||||
|
||||
| Aspect | node:http2 | fetch() |
|
||||
|--------|-----------|---------|
|
||||
| Session management | Explicit `session.connect()` / `session.close()` | Per-request (HTTP/2 connection reuse is automatic) |
|
||||
| Streaming upload | `stream.write()` chunks | `ReadableStream` body + `duplex: 'half'` |
|
||||
| Streaming download | `stream.on('data')` | `response.body.getReader()` |
|
||||
| Connection pooling | Manual | Automatic per origin |
|
||||
|
||||
### 2.4 API Changes
|
||||
|
||||
```typescript
|
||||
// Before (node:http2)
|
||||
export interface XFTPClient {
|
||||
session: http2.ClientHttp2Session
|
||||
thParams: THParams
|
||||
server: XFTPServer
|
||||
}
|
||||
|
||||
// After (fetch)
|
||||
export interface XFTPClient {
|
||||
baseUrl: string // "https://host:port"
|
||||
thParams: THParams
|
||||
server: XFTPServer
|
||||
}
|
||||
```
|
||||
|
||||
`connectXFTP()` performs handshake via fetch, returns `XFTPClient` with `baseUrl`.
|
||||
Subsequent commands use `fetch(client.baseUrl, ...)`.
|
||||
|
||||
### 2.5 Handshake via fetch()
|
||||
|
||||
**TLS session binding:** Multiple fetch() requests to the same origin reuse the HTTP/2 connection, which means they share the same TLS session. The server's `sessionId` (derived from TLS channel binding) remains consistent across the handshake round-trips and subsequent commands.
|
||||
|
||||
```typescript
|
||||
async function connectXFTP(server: XFTPServer): Promise<XFTPClient> {
|
||||
const baseUrl = `https://${server.host}:${server.port}`
|
||||
|
||||
// Round-trip 1: challenge → server handshake + identity proof
|
||||
const challenge = crypto.getRandomValues(new Uint8Array(32))
|
||||
const req1 = pad(encodeWebClientHello(challenge), xftpBlockSize)
|
||||
const resp1 = await fetch(baseUrl, {method: 'POST', body: req1})
|
||||
|
||||
const reader = resp1.body!.getReader()
|
||||
const serverBlock = await readExactly(reader, xftpBlockSize)
|
||||
const serverHs = decodeServerHandshake(unPad(serverBlock))
|
||||
const proofBody = await readRemaining(reader)
|
||||
verifyIdentityProof(server.keyHash, challenge, serverHs.sessionId, proofBody)
|
||||
|
||||
// Round-trip 2: client handshake → server ack
|
||||
const clientHs = encodeClientHandshake({xftpVersion: 3, keyHash: server.keyHash})
|
||||
const req2 = pad(clientHs, xftpBlockSize)
|
||||
await fetch(baseUrl, {method: 'POST', body: req2})
|
||||
|
||||
return {baseUrl, thParams: {sessionId: serverHs.sessionId, ...}, server}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 Command Execution
|
||||
|
||||
```typescript
|
||||
async function sendXFTPCommand(
|
||||
client: XFTPClient,
|
||||
key: Uint8Array,
|
||||
entityId: Uint8Array,
|
||||
cmd: Uint8Array,
|
||||
chunkData?: Uint8Array
|
||||
): Promise<{response: Uint8Array, body?: ReadableStream}> {
|
||||
const block = xftpEncodeAuthTransmission(client.thParams, key, entityId, cmd)
|
||||
|
||||
const reqBody = chunkData
|
||||
? concatBytes(block, chunkData)
|
||||
: block
|
||||
|
||||
const resp = await fetch(client.baseUrl, {
|
||||
method: 'POST',
|
||||
body: reqBody,
|
||||
duplex: 'half',
|
||||
})
|
||||
|
||||
const reader = resp.body!.getReader()
|
||||
const responseBlock = await readExactly(reader, xftpBlockSize)
|
||||
const parsed = xftpDecodeTransmission(responseBlock)
|
||||
|
||||
// For FGET: remaining body is encrypted chunk
|
||||
const hasMore = await peekReader(reader)
|
||||
return {
|
||||
response: parsed,
|
||||
body: hasMore ? wrapAsStream(reader) : undefined
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Architecture: Environment Abstraction
|
||||
|
||||
### 3.1 Core Principle
|
||||
|
||||
**Existing crypto functions remain unchanged.** The functions `encryptFile()`, `decryptChunks()`, `sha512()`, etc. in `crypto/file.ts` and `crypto/digest.ts` are pure computation — they take input bytes and produce output bytes. They have no knowledge of Workers, OPFS, or execution context.
|
||||
|
||||
The abstraction layer sits between `agent.ts` (upload/download orchestration) and these crypto functions:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ agent.ts (upload/download orchestration) │
|
||||
│ - Unchanged logic: encrypt → chunk → upload → build description │
|
||||
│ - Calls CryptoBackend interface, not crypto functions directly │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ CryptoBackend interface (env.ts) │
|
||||
│ - Abstract interface for encrypt/decrypt/readChunk/writeChunk │
|
||||
│ - Factory function selects implementation based on environment │
|
||||
├──────────────┬──────────────────────┬───────────────────────────────┤
|
||||
│ DirectMemory │ WorkerMemory │ WorkerOPFS │
|
||||
│ Backend │ Backend │ Backend │
|
||||
│ (Node.js) │ (Browser, ≤50MB) │ (Browser, >50MB) │
|
||||
├──────────────┼──────────────────────┼───────────────────────────────┤
|
||||
│ Calls crypto │ Posts to Worker, │ Posts to Worker, │
|
||||
│ functions │ Worker calls crypto │ Worker calls crypto, │
|
||||
│ directly │ functions, returns │ streams through OPFS │
|
||||
│ │ via postMessage │ │
|
||||
├──────────────┴──────────────────────┴───────────────────────────────┤
|
||||
│ crypto/file.ts, crypto/digest.ts (unchanged) │
|
||||
│ - encryptFile(), decryptChunks(), sha512(), etc. │
|
||||
│ - Pure functions, no environment dependencies │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 CryptoBackend Interface
|
||||
|
||||
```typescript
|
||||
// env.ts
|
||||
export interface CryptoBackend {
|
||||
// Encrypt file, store result (in memory or OPFS depending on backend)
|
||||
encrypt(
|
||||
data: Uint8Array,
|
||||
fileName: string,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<EncryptResult>
|
||||
|
||||
// Decrypt from stored encrypted data
|
||||
decrypt(
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
size: number,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<DecryptResult>
|
||||
|
||||
// Read chunk from stored encrypted data (for upload)
|
||||
readChunk(offset: number, size: number): Promise<Uint8Array>
|
||||
|
||||
// Write chunk to storage (for download, before decrypt)
|
||||
writeChunk(data: Uint8Array, offset: number): Promise<void>
|
||||
|
||||
// Clean up temporary storage
|
||||
cleanup(): Promise<void>
|
||||
}
|
||||
|
||||
export interface EncryptResult {
|
||||
digest: Uint8Array // SHA-512 of encrypted data
|
||||
key: Uint8Array // Generated encryption key
|
||||
nonce: Uint8Array // Generated nonce
|
||||
chunkSizes: number[] // Chunk sizes for upload
|
||||
totalSize: number // Total encrypted size
|
||||
}
|
||||
|
||||
export interface DecryptResult {
|
||||
header: FileHeader // Extracted file header (fileName, etc.)
|
||||
content: Uint8Array // Decrypted file content
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Backend Implementations
|
||||
|
||||
**DirectMemoryBackend** (Node.js):
|
||||
```typescript
|
||||
class DirectMemoryBackend implements CryptoBackend {
|
||||
private encryptedData: Uint8Array | null = null
|
||||
|
||||
async encrypt(data: Uint8Array, fileName: string, onProgress?): Promise<EncryptResult> {
|
||||
const key = randomBytes(32)
|
||||
const nonce = randomBytes(24)
|
||||
// Call existing crypto function directly
|
||||
this.encryptedData = encryptFile(data, fileName, key, nonce, onProgress)
|
||||
const digest = sha512(this.encryptedData)
|
||||
const chunkSizes = prepareChunkSizes(this.encryptedData.length)
|
||||
return { digest, key, nonce, chunkSizes, totalSize: this.encryptedData.length }
|
||||
}
|
||||
|
||||
async decrypt(key, nonce, size, onProgress): Promise<DecryptResult> {
|
||||
// Call existing crypto function directly
|
||||
return decryptChunks([this.encryptedData!], key, nonce, size, onProgress)
|
||||
}
|
||||
|
||||
async readChunk(offset: number, size: number): Promise<Uint8Array> {
|
||||
return this.encryptedData!.slice(offset, offset + size)
|
||||
}
|
||||
|
||||
async writeChunk(data: Uint8Array, offset: number): Promise<void> {
|
||||
if (!this.encryptedData) this.encryptedData = new Uint8Array(offset + data.length)
|
||||
this.encryptedData.set(data, offset)
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.encryptedData = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**WorkerMemoryBackend** and **WorkerOPFSBackend** are similar but post messages to a Web Worker instead of calling crypto directly. The Worker then calls the same `encryptFile()`, `decryptChunks()` functions. See §4 for Worker implementation details.
|
||||
|
||||
### 3.4 Factory Function
|
||||
|
||||
```typescript
|
||||
// env.ts
|
||||
export function createCryptoBackend(fileSize: number): CryptoBackend {
|
||||
const hasWorker = typeof Worker !== 'undefined'
|
||||
const hasOPFS = typeof navigator?.storage?.getDirectory !== 'undefined'
|
||||
const isLargeFile = fileSize > 50 * 1024 * 1024
|
||||
|
||||
if (hasWorker && hasOPFS && isLargeFile) {
|
||||
return new WorkerOPFSBackend() // Browser + large file
|
||||
} else if (hasWorker) {
|
||||
return new WorkerMemoryBackend() // Browser + small file
|
||||
} else {
|
||||
return new DirectMemoryBackend() // Node.js
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Usage in agent.ts
|
||||
|
||||
```typescript
|
||||
// agent.ts - upload orchestration (simplified)
|
||||
export async function uploadFile(
|
||||
server: XFTPServer,
|
||||
fileData: Uint8Array,
|
||||
fileName: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<string> {
|
||||
// Create backend based on environment
|
||||
const backend = createCryptoBackend(fileData.length)
|
||||
|
||||
try {
|
||||
// Encrypt (runs in Worker in browser, directly in Node)
|
||||
const enc = await backend.encrypt(fileData, fileName, onProgress)
|
||||
|
||||
// Upload chunks (same code regardless of backend)
|
||||
const client = await connectXFTP(server)
|
||||
const sentChunks = []
|
||||
let offset = 0
|
||||
for (const size of enc.chunkSizes) {
|
||||
const chunk = await backend.readChunk(offset, size)
|
||||
const sent = await uploadChunk(client, chunk, enc.digest)
|
||||
sentChunks.push(sent)
|
||||
offset += size
|
||||
}
|
||||
|
||||
// Build description and URI
|
||||
const fd = buildFileDescription(enc, sentChunks)
|
||||
return encodeFileDescriptionURI(fd)
|
||||
} finally {
|
||||
await backend.cleanup()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key point: `uploadFile()` logic is identical regardless of whether crypto runs in a Worker or directly. The `CryptoBackend` abstraction hides that detail.
|
||||
|
||||
### 3.6 Why This Matters for Testing
|
||||
|
||||
- **Layer 1 tests** (per-function): Call `encryptFile()`, `decryptChunks()` directly via Node — unchanged
|
||||
- **Layer 2 tests** (full flow): Call `uploadFile()`, `downloadFile()` in Node — uses `DirectMemoryBackend`, same code path as browser except for Worker
|
||||
- **Layer 3 tests** (browser): Call `uploadFile()`, `downloadFile()` in Playwright — uses `WorkerMemoryBackend` or `WorkerOPFSBackend`
|
||||
|
||||
All three layers exercise the same crypto functions. The only difference is execution context.
|
||||
|
||||
## 4. Web Worker Implementation
|
||||
|
||||
### 4.1 Why Web Worker
|
||||
|
||||
File encryption (XSalsa20-Poly1305) is sequential and CPU-bound:
|
||||
- 100 MB file ≈ 1-2 seconds of continuous computation
|
||||
- Running on main thread blocks UI (no progress updates, frozen page)
|
||||
- Chunking into async microtasks adds complexity and still causes jank
|
||||
|
||||
Web Worker runs crypto in parallel thread. Main thread stays responsive.
|
||||
|
||||
### 4.2 Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Main Thread │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ UI (upload/ │ │ Progress │ │ Network (fetch) │ │
|
||||
│ │ download) │ │ display │ │ │ │
|
||||
│ └──────┬──────┘ └──────▲──────┘ └──────────▲──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ postMessage │ progress │ encrypted │
|
||||
│ ▼ │ events │ chunks │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Web Worker │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ Crypto Pipeline ││
|
||||
│ │ - encryptFile() with progress callbacks ││
|
||||
│ │ - decryptChunks() with progress callbacks ││
|
||||
│ │ - OPFS read/write for temp storage ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 Message Protocol
|
||||
|
||||
**Main → Worker:**
|
||||
|
||||
```typescript
|
||||
type WorkerRequest =
|
||||
// Encrypt file, store result in OPFS (large) or memory (small)
|
||||
| {type: 'encrypt', file: File, fileName: string, useOPFS: boolean}
|
||||
// Read encrypted chunk from OPFS for upload
|
||||
| {type: 'readChunk', offset: number, size: number}
|
||||
// Write downloaded chunk to OPFS for later decryption
|
||||
| {type: 'writeChunk', data: ArrayBuffer, offset: number}
|
||||
// Decrypt from OPFS or provided chunks
|
||||
| {type: 'decrypt', key: Uint8Array, nonce: Uint8Array, size: number, chunks?: ArrayBuffer[]}
|
||||
// Delete OPFS temp files
|
||||
| {type: 'cleanup'}
|
||||
| {type: 'cancel'}
|
||||
```
|
||||
|
||||
**Worker → Main:**
|
||||
|
||||
```typescript
|
||||
type WorkerResponse =
|
||||
| {type: 'progress', phase: 'encrypt' | 'decrypt', done: number, total: number}
|
||||
// For OPFS: encData is empty, data lives in OPFS temp file
|
||||
| {type: 'encrypted', encData: ArrayBuffer | null, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array, chunkSizes: number[]}
|
||||
| {type: 'chunk', data: ArrayBuffer} // Response to readChunk
|
||||
| {type: 'chunkWritten'} // Response to writeChunk
|
||||
| {type: 'decrypted', header: FileHeader, content: ArrayBuffer}
|
||||
| {type: 'cleaned'} // Response to cleanup
|
||||
| {type: 'error', message: string}
|
||||
```
|
||||
|
||||
### 4.4 Worker Implementation
|
||||
|
||||
```typescript
|
||||
// crypto.worker.ts
|
||||
import {encryptFile, encryptFileStreaming, decryptChunks, decryptFromOPFS} from './crypto/file.js'
|
||||
import {sha512} from './crypto/digest.js'
|
||||
import {prepareChunkSizes} from './protocol/chunks.js'
|
||||
|
||||
let opfsHandle: FileSystemSyncAccessHandle | null = null
|
||||
|
||||
self.onmessage = async (e: MessageEvent<WorkerRequest>) => {
|
||||
const req = e.data
|
||||
|
||||
if (req.type === 'encrypt') {
|
||||
const key = crypto.getRandomValues(new Uint8Array(32))
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(24))
|
||||
|
||||
if (req.useOPFS) {
|
||||
// Large file: stream through OPFS to avoid memory pressure
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const fileHandle = await root.getFileHandle('encrypted-temp', {create: true})
|
||||
opfsHandle = await fileHandle.createSyncAccessHandle()
|
||||
|
||||
// Stream encrypt: read 64KB from File, encrypt, write to OPFS
|
||||
const digest = await encryptFileStreaming(
|
||||
req.file,
|
||||
req.fileName,
|
||||
key,
|
||||
nonce,
|
||||
opfsHandle,
|
||||
(done, total) => self.postMessage({type: 'progress', phase: 'encrypt', done, total})
|
||||
)
|
||||
|
||||
const encSize = opfsHandle.getSize()
|
||||
const chunkSizes = prepareChunkSizes(encSize)
|
||||
|
||||
self.postMessage({
|
||||
type: 'encrypted',
|
||||
encData: null, // Data in OPFS, not memory
|
||||
digest, key, nonce, chunkSizes
|
||||
})
|
||||
} else {
|
||||
// Small file: in-memory is fine
|
||||
const source = new Uint8Array(await req.file.arrayBuffer())
|
||||
const encData = encryptFile(source, req.fileName, key, nonce, (done, total) => {
|
||||
self.postMessage({type: 'progress', phase: 'encrypt', done, total})
|
||||
})
|
||||
|
||||
const digest = sha512(encData)
|
||||
const chunkSizes = prepareChunkSizes(encData.length)
|
||||
|
||||
self.postMessage({
|
||||
type: 'encrypted',
|
||||
encData: encData.buffer,
|
||||
digest, key, nonce, chunkSizes
|
||||
}, [encData.buffer])
|
||||
}
|
||||
}
|
||||
|
||||
if (req.type === 'readChunk') {
|
||||
// Read chunk from OPFS for upload
|
||||
const chunk = new Uint8Array(req.size)
|
||||
opfsHandle!.read(chunk, {at: req.offset})
|
||||
self.postMessage({type: 'chunk', data: chunk.buffer}, [chunk.buffer])
|
||||
}
|
||||
|
||||
if (req.type === 'writeChunk') {
|
||||
// Write downloaded chunk to OPFS
|
||||
if (!opfsHandle) {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const fileHandle = await root.getFileHandle('download-temp', {create: true})
|
||||
opfsHandle = await fileHandle.createSyncAccessHandle()
|
||||
}
|
||||
opfsHandle.write(new Uint8Array(req.data), {at: req.offset})
|
||||
self.postMessage({type: 'chunkWritten'})
|
||||
}
|
||||
|
||||
if (req.type === 'decrypt') {
|
||||
let result
|
||||
if (req.chunks) {
|
||||
// Small file: chunks provided in memory
|
||||
const chunks = req.chunks.map(b => new Uint8Array(b))
|
||||
result = decryptChunks(chunks, req.key, req.nonce, req.size, (done, total) => {
|
||||
self.postMessage({type: 'progress', phase: 'decrypt', done, total})
|
||||
})
|
||||
} else {
|
||||
// Large file: read from OPFS
|
||||
result = decryptFromOPFS(opfsHandle!, req.key, req.nonce, req.size, (done, total) => {
|
||||
self.postMessage({type: 'progress', phase: 'decrypt', done, total})
|
||||
})
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: 'decrypted',
|
||||
header: result.header,
|
||||
content: result.content.buffer
|
||||
}, [result.content.buffer])
|
||||
}
|
||||
|
||||
if (req.type === 'cleanup') {
|
||||
if (opfsHandle) {
|
||||
opfsHandle.close()
|
||||
opfsHandle = null
|
||||
}
|
||||
const root = await navigator.storage.getDirectory()
|
||||
try { await root.removeEntry('encrypted-temp') } catch {}
|
||||
try { await root.removeEntry('download-temp') } catch {}
|
||||
self.postMessage({type: 'cleaned'})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Main Thread Wrapper
|
||||
|
||||
```typescript
|
||||
// crypto-worker.ts (main thread)
|
||||
export class CryptoWorker {
|
||||
private worker: Worker
|
||||
private pending: Map<string, {resolve: Function, reject: Function}> = new Map()
|
||||
private onProgress?: (done: number, total: number) => void
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL('./crypto.worker.js', import.meta.url), {type: 'module'})
|
||||
this.worker.onmessage = (e) => this.handleMessage(e.data)
|
||||
}
|
||||
|
||||
async encrypt(file: File, onProgress?: (done: number, total: number) => void): Promise<EncryptedFileInfo> {
|
||||
const useOPFS = file.size > 50 * 1024 * 1024 // 50 MB threshold
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set('encrypt', {resolve, reject})
|
||||
this.onProgress = onProgress
|
||||
this.worker.postMessage({type: 'encrypt', file, fileName: file.name, useOPFS})
|
||||
})
|
||||
}
|
||||
|
||||
async decrypt(
|
||||
chunks: Uint8Array[],
|
||||
key: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
size: number,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<DownloadResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set('decrypt', {resolve, reject})
|
||||
this.onProgress = onProgress
|
||||
this.worker.postMessage({
|
||||
type: 'decrypt',
|
||||
chunks: chunks.map(c => c.buffer),
|
||||
key, nonce, size
|
||||
}, chunks.map(c => c.buffer))
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: WorkerResponse) {
|
||||
if (msg.type === 'progress') {
|
||||
this.onProgress?.(msg.done, msg.total)
|
||||
} else if (msg.type === 'encrypted') {
|
||||
this.pending.get('encrypt')?.resolve({
|
||||
encData: msg.encData ? new Uint8Array(msg.encData) : null, // null when using OPFS
|
||||
digest: msg.digest,
|
||||
key: msg.key,
|
||||
nonce: msg.nonce,
|
||||
chunkSizes: msg.chunkSizes
|
||||
})
|
||||
} else if (msg.type === 'decrypted') {
|
||||
this.pending.get('decrypt')?.resolve({
|
||||
header: msg.header,
|
||||
content: new Uint8Array(msg.content)
|
||||
})
|
||||
} else if (msg.type === 'error') {
|
||||
// Reject all pending
|
||||
for (const p of this.pending.values()) p.reject(new Error(msg.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. OPFS Implementation
|
||||
|
||||
### 5.1 Purpose
|
||||
|
||||
For files approaching 100 MB, holding encrypted data in memory while uploading creates memory pressure. OPFS provides temporary file storage:
|
||||
- Write encrypted data to OPFS as it's generated
|
||||
- Read chunks from OPFS for upload
|
||||
- Delete after upload completes
|
||||
|
||||
### 5.2 When to Use
|
||||
|
||||
- Files > 50 MB: Use OPFS
|
||||
- Files ≤ 50 MB: In-memory (simpler, no OPFS overhead)
|
||||
|
||||
Threshold is configurable.
|
||||
|
||||
### 5.3 OPFS API
|
||||
|
||||
```typescript
|
||||
// In Web Worker (synchronous API for performance)
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const fileHandle = await root.getFileHandle('encrypted-temp', {create: true})
|
||||
const accessHandle = await fileHandle.createSyncAccessHandle()
|
||||
|
||||
// Write encrypted chunks as they're generated
|
||||
accessHandle.write(encryptedChunk, {at: offset})
|
||||
|
||||
// Read chunk for upload
|
||||
const chunk = new Uint8Array(chunkSize)
|
||||
accessHandle.read(chunk, {at: chunkOffset})
|
||||
|
||||
// Cleanup
|
||||
accessHandle.close()
|
||||
await root.removeEntry('encrypted-temp')
|
||||
```
|
||||
|
||||
### 5.4 Upload Flow with OPFS
|
||||
|
||||
```
|
||||
1. Main: user drops file
|
||||
2. Main → Worker: {type: 'encrypt', file}
|
||||
3. Worker:
|
||||
- Create OPFS temp file
|
||||
- Encrypt 64KB at a time, write to OPFS
|
||||
- Post progress every 64KB
|
||||
- Compute digest
|
||||
- Return {digest, key, nonce, chunkSizes} (data stays in OPFS)
|
||||
4. Main: for each chunk:
|
||||
- Main → Worker: {type: 'readChunk', offset, size}
|
||||
- Worker: read from OPFS, return chunk
|
||||
- Main: upload chunk via fetch()
|
||||
5. Main → Worker: {type: 'cleanup'}
|
||||
6. Worker: delete OPFS temp file
|
||||
```
|
||||
|
||||
### 5.5 Download Flow with OPFS
|
||||
|
||||
```
|
||||
1. Main: parse URL, get FileDescription
|
||||
2. Main: for each chunk:
|
||||
- Download via fetch()
|
||||
- Main → Worker: {type: 'writeChunk', data, offset}
|
||||
- Worker: write to OPFS temp file
|
||||
3. Main → Worker: {type: 'decrypt', key, nonce, size}
|
||||
4. Worker:
|
||||
- Read from OPFS
|
||||
- Decrypt, verify auth tag
|
||||
- Return {header, content}
|
||||
5. Main: trigger browser download
|
||||
6. Main → Worker: {type: 'cleanup'}
|
||||
```
|
||||
|
||||
## 6. Implementation Plan
|
||||
|
||||
### 6.1 Phase A: fetch() Transport
|
||||
|
||||
**Goal:** Replace `node:http2` with `fetch()` in `client.ts`. All existing Node.js tests pass.
|
||||
|
||||
1. Rewrite `connectXFTP()` to use fetch() for handshake
|
||||
2. Rewrite `sendXFTPCommand()` to use fetch()
|
||||
3. Update `createXFTPChunk`, `uploadXFTPChunk`, `downloadXFTPChunk`, etc.
|
||||
4. Remove `node:http2` import
|
||||
5. Run existing Haskell integration tests — must pass
|
||||
|
||||
**Files:** `client.ts`
|
||||
|
||||
### 6.2 Phase B: Environment Abstraction + Web Worker
|
||||
|
||||
**Goal:** Add `CryptoBackend` abstraction (§3) so the same code works in Node (direct) and browser (Worker).
|
||||
|
||||
1. Create `env.ts` with `CryptoBackend` interface and `createCryptoBackend()` factory (as specified in §3)
|
||||
2. Implement `DirectMemoryBackend` for Node.js
|
||||
3. Create `crypto.worker.ts` that imports and calls existing crypto functions
|
||||
4. Implement `WorkerMemoryBackend` for browser
|
||||
5. Update `agent.ts` to use `createCryptoBackend()` instead of direct crypto calls
|
||||
6. Existing tests pass (now using `DirectMemoryBackend`)
|
||||
|
||||
**Files:** `env.ts`, `crypto.worker.ts`, `agent.ts`
|
||||
|
||||
### 6.3 Phase C: OPFS Backend
|
||||
|
||||
**Goal:** Large files (>50 MB) use OPFS for temp storage in browser.
|
||||
|
||||
1. Implement `WorkerOPFSBackend` — uses OPFS sync API in worker
|
||||
2. Add OPFS helpers in worker: read/write to temp file
|
||||
3. Factory function now returns `WorkerOPFSBackend` for large files
|
||||
4. Same `agent.ts` code works — only backend implementation differs
|
||||
|
||||
**Files:** `env.ts`, `crypto.worker.ts`
|
||||
|
||||
### 6.4 Phase D: Browser Testing
|
||||
|
||||
**Goal:** Verify everything works in real browsers.
|
||||
|
||||
1. Create minimal test HTML page
|
||||
2. Test upload flow in Chrome, Firefox, Safari
|
||||
3. Test download flow
|
||||
4. Test progress reporting
|
||||
5. Test cancellation
|
||||
6. Test error handling (network failure, invalid file)
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Test Layers
|
||||
|
||||
The `CryptoBackend` abstraction (§3) enables testing at multiple levels without code duplication:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 3: Browser Integration (Playwright) │
|
||||
│ - Web Worker message passing │
|
||||
│ - OPFS read/write │
|
||||
│ - Progress UI updates │
|
||||
│ - Real browser fetch() with CORS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Layer 2: Full Flow (Haskell-driven, Node.js) │
|
||||
│ - fetch() transport against real xftp-server │
|
||||
│ - Upload: encrypt → chunk → upload → build description │
|
||||
│ - Download: parse → download → verify → decrypt │
|
||||
│ - Cross-language: TS upload ↔ Haskell download (and vice versa) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Layer 1: Per-Function (Haskell-driven, Node.js) │
|
||||
│ - 172 existing tests │
|
||||
│ - Byte-identical output vs Haskell functions │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 Layer 1: Per-Function Tests (Existing)
|
||||
|
||||
Existing Haskell-driven tests in `XFTPWebTests.hs`. Each test calls a TypeScript function via Node and compares output with Haskell.
|
||||
|
||||
```bash
|
||||
cabal test --ghc-options -O0 --test-option='--match=/XFTP Web Client/'
|
||||
```
|
||||
|
||||
All 172 tests must pass. No changes needed for browser transport work.
|
||||
|
||||
### 7.3 Layer 2: Full Flow Tests (Node.js + fetch)
|
||||
|
||||
Haskell-driven integration tests using Node.js native fetch(). These test the complete upload/download flow without Worker/OPFS.
|
||||
|
||||
```haskell
|
||||
-- XFTPWebTests.hs (extends existing test file)
|
||||
it "fetch transport: upload and download round-trip" $ do
|
||||
withXFTPServer testXFTPServerConfigSNI $ \server -> do
|
||||
-- TypeScript uploads via fetch(), returns URI
|
||||
uri <- jsOut $ callTS "src/agent" "uploadFileTest" serverAddrHex <> testFileHex
|
||||
-- TypeScript downloads via fetch()
|
||||
content <- jsOut $ callTS "src/agent" "downloadFileTest" uriHex
|
||||
content `shouldBe` testFileContent
|
||||
|
||||
it "fetch transport: TS upload, Haskell download" $ do
|
||||
withXFTPServer testXFTPServerConfigSNI $ \server -> do
|
||||
uri <- jsOut $ callTS "src/agent" "uploadFileTest" serverAddrHex <> testFileHex
|
||||
-- Haskell agent downloads using existing xftp CLI pattern
|
||||
outPath <- withAgent 1 agentCfg initAgentServers testDB $ \a -> do
|
||||
rfId <- xftpReceiveFile' a 1 uri Nothing
|
||||
waitRfDone a
|
||||
content <- B.readFile outPath
|
||||
content `shouldBe` testFileContent
|
||||
```
|
||||
|
||||
**What this tests:**
|
||||
- fetch() handshake (challenge-response, TLS session binding)
|
||||
- fetch() command execution (FNEW, FPUT, FGET, FACK)
|
||||
- Streaming request/response bodies
|
||||
- Full encrypt → upload → download → decrypt flow
|
||||
|
||||
**What this doesn't test:**
|
||||
- Web Worker message passing
|
||||
- OPFS storage
|
||||
- Browser-specific fetch() behavior (CORS preflight, etc.)
|
||||
|
||||
### 7.4 Layer 3: Browser Integration Tests (Playwright)
|
||||
|
||||
Playwright tests run in real browsers, testing browser-specific functionality.
|
||||
|
||||
**Test infrastructure:**
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── test/
|
||||
│ ├── browser.test.ts # Playwright test file
|
||||
│ └── test-server.ts # Spawns xftp-server for tests
|
||||
└── test-page/
|
||||
├── index.html # Minimal test UI
|
||||
└── test-harness.ts # Exposes test functions to window
|
||||
```
|
||||
|
||||
**Running browser tests:**
|
||||
|
||||
```bash
|
||||
cd xftp-web
|
||||
npm run test:browser # Spawns xftp-server, runs Playwright
|
||||
```
|
||||
|
||||
**Test cases:**
|
||||
|
||||
```typescript
|
||||
// test/browser.test.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
let serverProcess: ChildProcess
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Spawn xftp-server with SNI cert for browser TLS
|
||||
serverProcess = spawn('xftp-server', ['start', '-c', 'test-config.ini'])
|
||||
await waitForServer()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
serverProcess.kill()
|
||||
})
|
||||
|
||||
test('small file upload/download (in-memory)', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const data = new Uint8Array(1024 * 1024) // 1 MB
|
||||
crypto.getRandomValues(data)
|
||||
const file = new File([data], 'small.bin')
|
||||
|
||||
const uri = await window.xftp.uploadFile(file)
|
||||
const downloaded = await window.xftp.downloadFile(uri)
|
||||
|
||||
return {
|
||||
uploadedSize: data.length,
|
||||
downloadedSize: downloaded.length,
|
||||
match: arraysEqual(data, downloaded),
|
||||
usedOPFS: window.xftp.lastUploadUsedOPFS
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.match).toBe(true)
|
||||
expect(result.usedOPFS).toBe(false) // Small file, no OPFS
|
||||
})
|
||||
|
||||
test('large file upload/download (OPFS)', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const data = new Uint8Array(60 * 1024 * 1024) // 60 MB
|
||||
crypto.getRandomValues(data)
|
||||
const file = new File([data], 'large.bin')
|
||||
|
||||
const uri = await window.xftp.uploadFile(file)
|
||||
const downloaded = await window.xftp.downloadFile(uri)
|
||||
|
||||
return {
|
||||
match: arraysEqual(data, downloaded),
|
||||
usedOPFS: window.xftp.lastUploadUsedOPFS
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.match).toBe(true)
|
||||
expect(result.usedOPFS).toBe(true) // Large file, used OPFS
|
||||
})
|
||||
|
||||
test('progress events fire during upload', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
const progressEvents = await page.evaluate(async () => {
|
||||
const events: number[] = []
|
||||
const data = new Uint8Array(10 * 1024 * 1024) // 10 MB
|
||||
const file = new File([data], 'progress.bin')
|
||||
|
||||
await window.xftp.uploadFile(file, (done, total) => {
|
||||
events.push(done / total)
|
||||
})
|
||||
|
||||
return events
|
||||
})
|
||||
|
||||
expect(progressEvents.length).toBeGreaterThan(1)
|
||||
expect(progressEvents[progressEvents.length - 1]).toBe(1) // 100% at end
|
||||
})
|
||||
|
||||
test('Web Worker keeps UI responsive', async ({ page }) => {
|
||||
await page.goto('/test-page/')
|
||||
|
||||
// Start upload and measure main thread responsiveness
|
||||
const result = await page.evaluate(async () => {
|
||||
const data = new Uint8Array(50 * 1024 * 1024) // 50 MB
|
||||
const file = new File([data], 'responsive.bin')
|
||||
|
||||
let frameCount = 0
|
||||
let uploadDone = false
|
||||
|
||||
// Count animation frames during upload
|
||||
function countFrames() {
|
||||
frameCount++
|
||||
if (!uploadDone) requestAnimationFrame(countFrames)
|
||||
}
|
||||
requestAnimationFrame(countFrames)
|
||||
|
||||
const start = performance.now()
|
||||
await window.xftp.uploadFile(file)
|
||||
uploadDone = true
|
||||
const elapsed = performance.now() - start
|
||||
|
||||
// If main thread was blocked, frameCount would be very low
|
||||
const expectedFrames = (elapsed / 1000) * 30 // ~30 fps minimum
|
||||
return { frameCount, expectedFrames, elapsed }
|
||||
})
|
||||
|
||||
// Should maintain reasonable frame rate (Worker offloaded crypto)
|
||||
expect(result.frameCount).toBeGreaterThan(result.expectedFrames * 0.5)
|
||||
})
|
||||
```
|
||||
|
||||
### 7.5 Cross-Browser Matrix
|
||||
|
||||
| Browser | fetch streaming | Web Worker | OPFS sync | Status |
|
||||
|---------|----------------|------------|-----------|--------|
|
||||
| Chrome 105+ | ✓ | ✓ | ✓ | Primary target |
|
||||
| Firefox 111+ | ✓ | ✓ | ✓ | Supported |
|
||||
| Safari 16.4+ | ✓ | ✓ | ✓ | Supported |
|
||||
| Edge 105+ | ✓ | ✓ | ✓ | Supported (Chromium) |
|
||||
|
||||
Playwright tests run against Chrome by default. CI can run against all browsers.
|
||||
|
||||
### 7.6 Test Execution Summary
|
||||
|
||||
| Phase | Test Layer | Command | What's Verified |
|
||||
|-------|-----------|---------|-----------------|
|
||||
| A | Layer 1 + 2 | `cabal test --test-option='--match=/XFTP Web Client/'` | fetch() transport, full flow |
|
||||
| B | Layer 3 | `npm run test:browser` | Worker message passing, progress |
|
||||
| C | Layer 3 | `npm run test:browser` | OPFS storage for large files |
|
||||
| D | Layer 3 | `npm run test:browser -- --project=firefox,webkit` | Cross-browser |
|
||||
@@ -0,0 +1,772 @@
|
||||
# Send File Web Page — Implementation Plan
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Architecture
|
||||
3. CryptoBackend & Web Worker
|
||||
4. Server Configuration
|
||||
5. Page Structure & UI
|
||||
6. Upload Flow
|
||||
7. Download Flow
|
||||
8. Build & Dev Setup
|
||||
9. agent.ts Changes
|
||||
10. Testing
|
||||
11. Files
|
||||
12. Implementation Order
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Build a static web page for browser-based XFTP file transfer (Phase 5 of master RFC). The page supports upload (drag-drop → encrypt → upload → shareable link) and download (open link → download → decrypt → save). Crypto runs in a Web Worker; large files use OPFS temp storage.
|
||||
|
||||
Two build variants:
|
||||
- **Local**: single test server at `localhost:7000` (development/testing)
|
||||
- **Production**: 12 preset XFTP servers (6 SimpleX + 6 Flux)
|
||||
|
||||
Uses Vite for bundling (already a dependency via vitest). No CSS framework — plain CSS per RFC spec.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── src/ # Library (existing, targeted changes)
|
||||
│ ├── agent.ts # Modified: uploadFile readChunk, downloadFileRaw
|
||||
│ ├── client.ts # Modified: downloadXFTPChunkRaw
|
||||
│ ├── crypto/ # Unchanged
|
||||
│ ├── download.ts # Unchanged
|
||||
│ └── protocol/
|
||||
│ └── description.ts # Fix: SHA-256 → SHA-512 comment on digest field
|
||||
├── web/ # Web page (new)
|
||||
│ ├── index.html # Entry point (CSP meta tag)
|
||||
│ ├── main.ts # Router + sodium.ready init
|
||||
│ ├── upload.ts # Upload UI + orchestration
|
||||
│ ├── download.ts # Download UI + orchestration
|
||||
│ ├── progress.ts # Circular progress canvas component
|
||||
│ ├── servers.ts # Server list (build-time configured, imports servers.json)
|
||||
│ ├── servers.json # Preset server addresses (shared with vite.config.ts)
|
||||
│ ├── crypto-backend.ts # CryptoBackend interface + WorkerBackend
|
||||
│ ├── crypto.worker.ts # Web Worker: encrypt/decrypt/OPFS
|
||||
│ └── style.css # Minimal styling
|
||||
├── vite.config.ts # Page build config (new)
|
||||
├── tsconfig.web.json # IDE/CI type-check for web/ (new)
|
||||
├── tsconfig.worker.json # IDE/CI type-check for worker (new)
|
||||
├── playwright.config.ts # Page E2E test config (new)
|
||||
├── vitest.config.ts # Test config (existing)
|
||||
├── .gitignore # Existing (add dist-web/)
|
||||
└── test/ # Tests (existing + new page test)
|
||||
```
|
||||
|
||||
Data flow:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────┐
|
||||
│ Main Thread │
|
||||
│ │
|
||||
│ Upload: upload.ts ──► agent.ts ──► fetch()│
|
||||
│ Download: download.ts ──► agent.ts ──► fetch()
|
||||
│ │ │
|
||||
│ postMessage HTTP/2 │
|
||||
│ ▼ ▼
|
||||
│ ┌─────────────────┐ ┌──────────┐│
|
||||
│ │ Web Worker │ │ XFTP ││
|
||||
│ │ crypto.worker.ts │ │ Server ││
|
||||
│ │ ┌─────────────┐ │ └──────────┘│
|
||||
│ │ │ OPFS temp │ │ │
|
||||
│ │ └─────────────┘ │ │
|
||||
│ └─────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Both upload and download use `agent.ts` for orchestration (connection pooling, parallel chunk transfers, redirect handling). Upload uses a `readChunk` callback for Worker data access. Download uses an `onRawChunk` callback to route raw encrypted chunks to the Worker for decryption (see §7.2). ACK is the caller's responsibility — `downloadFileRaw` returns the resolved `FileDescription` without ACKing, so the caller can verify integrity before acknowledging.
|
||||
|
||||
## 3. CryptoBackend & Web Worker
|
||||
|
||||
### 3.1 Interface
|
||||
|
||||
```typescript
|
||||
// crypto-backend.ts
|
||||
export interface CryptoBackend {
|
||||
// Upload: encrypt file, store encrypted data in OPFS
|
||||
encrypt(data: Uint8Array, fileName: string,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<EncryptResult>
|
||||
|
||||
// Upload: read encrypted chunk from OPFS (called by agent.ts via readChunk callback)
|
||||
readChunk(offset: number, size: number): Promise<Uint8Array>
|
||||
|
||||
// Download: transit-decrypt raw chunk and store in OPFS
|
||||
decryptAndStoreChunk(
|
||||
dhSecret: Uint8Array, nonce: Uint8Array,
|
||||
body: Uint8Array, digest: Uint8Array, chunkNo: number
|
||||
): Promise<void>
|
||||
|
||||
// Download: verify digest + file-level decrypt all stored chunks
|
||||
// Only needs size/digest/key/nonce — not the full FileDescription (avoids sending private keys to Worker)
|
||||
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
|
||||
): Promise<{header: FileHeader, content: Uint8Array}>
|
||||
|
||||
cleanup(): Promise<void>
|
||||
}
|
||||
|
||||
// Structurally identical to EncryptedFileMetadata from agent.ts (§9.1).
|
||||
// Kept separate to avoid crypto-backend.ts importing from agent.ts
|
||||
// (which would pull in node:http2 via client.ts, breaking Worker bundling).
|
||||
// TypeScript structural typing makes them assignment-compatible.
|
||||
export interface EncryptResult {
|
||||
digest: Uint8Array
|
||||
key: Uint8Array
|
||||
nonce: Uint8Array
|
||||
chunkSizes: number[]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Factory
|
||||
|
||||
```typescript
|
||||
export function createCryptoBackend(): CryptoBackend {
|
||||
if (typeof Worker === 'undefined') {
|
||||
throw new Error('Web Workers required — update your browser')
|
||||
}
|
||||
return new WorkerBackend()
|
||||
}
|
||||
```
|
||||
|
||||
The Worker always uses OPFS for temp storage (single code path — no memory/disk branching). OPFS I/O overhead is negligible relative to crypto and network time. Each Worker session creates a unique directory in OPFS root named `session-<Date.now()>-<crypto.randomUUID()>`, containing `upload.bin` and `download.bin` as needed. `cleanup()` deletes the entire session directory. On Worker startup (before processing messages), sweep OPFS root and delete any `session-*` directories whose embedded timestamp (parsed from the name) is older than 1 hour — this handles stale files from crashed tabs. The OPFS API does not expose directory timestamps, so the name-encoded timestamp is the only reliable mechanism. This prevents cross-tab collisions and unbounded OPFS growth.
|
||||
|
||||
### 3.3 Worker message protocol
|
||||
|
||||
Every request carries a numeric `id`. Responses carry the same `id`. WorkerBackend maintains a `Map<number, {resolve, reject}>` to match responses to pending promises.
|
||||
|
||||
Main → Worker (fields marked `†` are Transferable — arrive as `ArrayBuffer` in Worker, must be wrapped with `new Uint8Array(...)` before use):
|
||||
- `{id: number, type: 'encrypt', data†: ArrayBuffer, fileName: string}` — encrypt file, store in OPFS
|
||||
- `{id: number, type: 'readChunk', offset: number, size: number}` — read encrypted chunk from OPFS
|
||||
- `{id: number, type: 'decryptAndStoreChunk', dhSecret: Uint8Array, nonce: Uint8Array, body†: ArrayBuffer, chunkDigest: Uint8Array, chunkNo: number}` — transit-decrypt + store in OPFS. `chunkDigest` is the per-chunk SHA-256 digest (verified by `decryptReceivedChunk`). Distinct from the file-level SHA-512 digest in `verifyAndDecrypt`.
|
||||
- `{id: number, type: 'verifyAndDecrypt', size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}` — verify digest + file-level decrypt all chunks. Only the four fields needed for verification/decryption are sent — not the full `FileDescription`, which contains private replica keys that the Worker doesn't need.
|
||||
- `{id: number, type: 'cleanup'}` — delete OPFS temp files
|
||||
|
||||
Worker → Main (fields marked `†` are Transferable):
|
||||
- `{id: number, type: 'progress', done: number, total: number}` — encryption/decryption progress (fire-and-forget, no promise)
|
||||
- `{id: number, type: 'encrypted', digest: Uint8Array, key: Uint8Array, nonce: Uint8Array, chunkSizes: number[]}` — all fields structured-cloned (not transferred)
|
||||
- `{id: number, type: 'chunk', data†: ArrayBuffer}` — readChunk response
|
||||
- `{id: number, type: 'stored'}` — decryptAndStore acknowledgment
|
||||
- `{id: number, type: 'decrypted', header: FileHeader, content†: ArrayBuffer}` — verifyAndDecrypt response
|
||||
- `{id: number, type: 'cleaned'}`
|
||||
- `{id: number, type: 'error', message: string}` — rejects the pending promise for this `id`
|
||||
|
||||
All messages carrying large `ArrayBuffer` payloads use `postMessage(msg, [transferables])` to transfer ownership instead of structured-clone copying. Only `ArrayBuffer` can be transferred — `Uint8Array`, `number[]`, and other types are always structured-cloned. This applies to: `encrypt` request (`data`), `readChunk` response (`data`), `decryptAndStoreChunk` request (`body`), and `verifyAndDecrypt` response (`content`). The `WorkerBackend` implementation must ensure the transferred `ArrayBuffer` covers the full `Uint8Array` — if `byteOffset !== 0` or `byteLength !== buffer.byteLength`, slice first: `data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)`. This is required for `decryptAndStore` request bodies: `sendXFTPCommand` returns `body = fullResp.subarray(XFTP_BLOCK_SIZE)`, which has `byteOffset = XFTP_BLOCK_SIZE`. Other payloads are full-buffer views (§6 step 3 creates `new Uint8Array(await file.arrayBuffer())`; Worker responses allocate fresh buffers) but `WorkerBackend` should guard unconditionally.
|
||||
|
||||
### 3.4 Worker internals
|
||||
|
||||
**Imports:** The Worker imports directly from `libsodium-wrappers-sumo` (for `await sodium.ready`), `src/crypto/file.js` (`encryptFile`, `encodeFileHeader`, `decryptChunks`), `src/crypto/digest.js` (`sha512`), `src/protocol/chunks.js` (`prepareChunkSizes`, `fileSizeLen`, `authTagSize`), `src/protocol/encoding.js` (`concatBytes`), and `src/download.js` (`decryptReceivedChunk`). `download.js` directly imports `src/protocol/client.js` (for `decryptTransportChunk`). These transitively pull in `src/crypto/secretbox.js`, `src/crypto/keys.js`, and `src/crypto/padding.js`. None of these import `src/agent.ts` or `src/client.ts` — those pull in `node:http2` via dynamic import which would break Worker bundling. Vite tree-shakes the transitive deps automatically. Note: `download.js` → `protocol/client.js` → `crypto/keys.js` transitively pulls in `@noble/curves` (~50-80KB). This is unavoidable since `decryptTransportChunk` needs `dh` from `keys.js`. If Worker bundle size becomes a concern, `decryptReceivedChunk` could be refactored out of `download.js` into a separate module that doesn't import `protocol/client.js`.
|
||||
|
||||
**ArrayBuffer → Uint8Array conversion:** All Transferable fields arrive in the Worker as `ArrayBuffer`. The Worker's message handler must wrap them before passing to library functions: `new Uint8Array(msg.data)` for encrypt, `new Uint8Array(msg.body)` for decryptAndStore. Non-transferred fields (`dhSecret`, `nonce`, `digest`, `chunkSizes`) arrive as their original types (`Uint8Array` / `number[]`) via structured clone.
|
||||
|
||||
The Worker's encrypt handler calls the same functions as `encryptFileForUpload` in agent.ts (key/nonce generation → `encryptFile` → `sha512` → `prepareChunkSizes`). This is not reimplementation — it's calling the same library functions from a different entry point.
|
||||
|
||||
**Libsodium init:** Both the Worker and the main thread must `await sodium.ready` before calling any crypto functions that use libsodium. The Worker does this once on startup before processing messages. The main thread needs it before `connectXFTP` (which uses libsodium via `verifyIdentityProof`) and before `downloadXFTPChunkRaw` (which uses libsodium via `generateX25519KeyPair` + `dh`). In practice, `main.ts` calls `await sodium.ready` at page load, before any XFTP calls.
|
||||
|
||||
Encrypt (mirrors `encryptFileForUpload` in agent.ts):
|
||||
1. Generate key (32B) + nonce (24B) via `crypto.getRandomValues`
|
||||
2. `fileHdr = encodeFileHeader({fileName, fileExtra: null})`
|
||||
3. `fileSize = BigInt(fileHdr.length + source.length)`
|
||||
4. `payloadSize = Number(fileSize) + fileSizeLen + authTagSize`
|
||||
5. `chunkSizes = prepareChunkSizes(payloadSize)`
|
||||
6. `encSize = BigInt(chunkSizes.reduce((a, b) => a + b, 0))`
|
||||
7. `encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)`
|
||||
8. `digest = sha512(encData)` — note: the `digest` field comment in `FileDescription` in `description.ts` says "SHA-256" but the actual hash is SHA-512 everywhere (`sha512` in agent.ts and download.ts). Fix the comment during implementation.
|
||||
9. Open OPFS upload file via `createSyncAccessHandle`, write `encData`, flush, close handle. Null out `encData` reference.
|
||||
10. Reopen the same OPFS file with `createSyncAccessHandle` as a persistent read handle (stored on the Worker module scope). This handle is used by all subsequent `readChunk` calls and closed on `cleanup`.
|
||||
11. Post back `{digest, key, nonce, chunkSizes}` (no encData transfer — data stays in OPFS)
|
||||
|
||||
readChunk:
|
||||
- Use the persistent read handle: `handle.read(buf, {at: offset})` → return slice as transferable ArrayBuffer. OPFS allows only one `FileSystemSyncAccessHandle` per file; the persistent handle avoids per-call open/close overhead.
|
||||
|
||||
decryptAndStoreChunk (removes transport encryption only — stored data is still file-level encrypted):
|
||||
1. `decryptReceivedChunk(dhSecret, nonce, new Uint8Array(body), chunkDigest)` → transit-decrypted chunk data (still file-level encrypted — only the transport layer is removed). Argument order matches signature `(dhSecret, cbNonce, encData, expectedDigest)` from download.ts. `body` arrives as `ArrayBuffer` via Transferable and must be wrapped; `dhSecret`, `nonce`, `chunkDigest` arrive as `Uint8Array` via structured clone.
|
||||
2. On first call, open the OPFS download temp file via `createSyncAccessHandle` and store as a persistent write handle. Record `{chunkNo, size: decrypted.length}` in an in-memory `chunkMeta: Map<number, {offset: number, size: number}>` — offset is the running sum of sizes for chunks stored so far (chunks may arrive out of order with `concurrency > 1`, so offset is assigned as `currentFileOffset`, then `currentFileOffset += size`)
|
||||
3. Write decrypted chunk to the persistent handle at the recorded offset
|
||||
|
||||
verifyAndDecrypt (mirrors size/digest checks in agent.ts `downloadFile`):
|
||||
1. Close the persistent download write handle (flush first), then reopen as a read handle. Read each chunk from OPFS into a `Uint8Array[]` array, ordered by `chunkNo`: for each entry in `chunkMeta` sorted by `chunkNo`, `handle.read(buf, {at: offset})` with the recorded offset and size
|
||||
2. Concatenate for verification: `combined = concatBytes(...chunks)`
|
||||
3. Verify total size: `combined.length === params.size`
|
||||
4. Verify SHA-512 digest: `sha512(combined)` matches `params.digest`
|
||||
5. Decrypt: `decryptChunks(BigInt(params.size), chunks, params.key, params.nonce)` — `params.size` is the encrypted file size (`fd.size` = `sum(chunkSizes)` = `decryptChunks`' first param `encSize`). Called directly instead of via `processDownloadedFile` (which expects a full `FileDescription`). Pass the original `chunks` array (not `combined`), as `decryptChunks` handles concatenation internally.
|
||||
6. Delete OPFS download temp file
|
||||
7. Return `{header, content}` via transferable ArrayBuffer
|
||||
|
||||
### 3.5 Browser requirements
|
||||
|
||||
The page requires a modern browser with Web Worker and OPFS support:
|
||||
- Chrome 102+, Firefox 114+, Safari 15.2+ (Workers + OPFS + ES module Workers — Firefox added module Worker support in 114)
|
||||
- If Worker or OPFS is unavailable, the page shows an error message rather than falling back silently.
|
||||
|
||||
No `DirectBackend` is needed — the page is browser-only, and tests run in vitest browser mode (real Chromium). The existing library tests (`test/browser.test.ts`) test the crypto/upload/download pipeline directly without Workers.
|
||||
|
||||
## 4. Server Configuration
|
||||
|
||||
### 4.1 Server lists
|
||||
|
||||
`web/servers.json` — single source of truth for preset server addresses (imported by both `servers.ts` and `vite.config.ts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"simplex": [
|
||||
"xftp://da1aH3nOT-9G8lV7bWamhxpDYdJ1xmW7j3JpGaDR5Ug=@xftp1.simplex.im",
|
||||
"xftp://5vog2Imy1ExJB_7zDZrkV1KDWi96jYFyy9CL6fndBVw=@xftp2.simplex.im",
|
||||
"xftp://PYa32DdYNFWi0uZZOprWQoQpIk5qyjRJ3EF7bVpbsn8=@xftp3.simplex.im",
|
||||
"xftp://k_GgQl40UZVV0Y4BX9ZTyMVqX5ZewcLW0waQIl7AYDE=@xftp4.simplex.im",
|
||||
"xftp://-bIo6o8wuVc4wpZkZD3tH-rCeYaeER_0lz1ffQcSJDs=@xftp5.simplex.im",
|
||||
"xftp://6nSvtY9pJn6PXWTAIMNl95E1Kk1vD7FM2TeOA64CFLg=@xftp6.simplex.im"
|
||||
],
|
||||
"flux": [
|
||||
"xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com",
|
||||
"xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com",
|
||||
"xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com",
|
||||
"xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com",
|
||||
"xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com",
|
||||
"xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`web/servers.ts`:
|
||||
|
||||
```typescript
|
||||
import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js'
|
||||
import presets from './servers.json'
|
||||
|
||||
declare const __XFTP_SERVERS__: string[]
|
||||
|
||||
const serverAddresses: string[] = typeof __XFTP_SERVERS__ !== 'undefined'
|
||||
? __XFTP_SERVERS__
|
||||
: [...presets.simplex, ...presets.flux]
|
||||
|
||||
export function getServers(): XFTPServer[] {
|
||||
return serverAddresses.map(parseXFTPServer)
|
||||
}
|
||||
|
||||
export function pickRandomServer(servers: XFTPServer[]): XFTPServer {
|
||||
return servers[Math.floor(Math.random() * servers.length)]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Build-time injection
|
||||
|
||||
`vite.config.ts` defines `__XFTP_SERVERS__`:
|
||||
- `mode === 'local'`: `["xftp://<test-fingerprint>@localhost:7000"]`
|
||||
- `mode === 'production'`: not defined → falls through to hardcoded list
|
||||
|
||||
### 4.3 Assumption
|
||||
|
||||
Production XFTP servers must have `[WEB]` section configured with a CA-signed certificate for browser TLS. Without this, browsers will reject the self-signed XFTP identity cert. The local test server uses `tests/fixtures/` certs which Chromium accepts via `ignoreHTTPSErrors`.
|
||||
|
||||
## 5. Page Structure & UI
|
||||
|
||||
### 5.1 Routing
|
||||
|
||||
`main.ts` checks `window.location.hash` once on page load:
|
||||
- Hash present → download mode
|
||||
- Hash absent → upload mode
|
||||
|
||||
No `hashchange` listener — the shareable link opens in a new tab. Simple page-load routing.
|
||||
|
||||
### 5.2 Upload UI states
|
||||
|
||||
1. **Landing**: Drag-drop zone centered, file picker button, size limit note
|
||||
2. **Uploading**: Circular progress (canvas), percentage, cancel button
|
||||
3. **Complete**: Shareable link (input + copy button), "Install SimpleX" CTA
|
||||
4. **Error**: Error message + retry button. On server-unreachable, auto-retry with exponential backoff (1s, 2s, 4s, up to 3 attempts) before showing the error state.
|
||||
|
||||
### 5.3 Download UI states
|
||||
|
||||
1. **Ready**: Approximate file size displayed (encrypted size from `fd.size` or `fd.redirect.size` — see §7 step 2; file name is unavailable — it's inside the encrypted content), download button
|
||||
2. **Downloading**: Circular progress, percentage
|
||||
3. **Complete**: Browser save dialog triggered automatically
|
||||
4. **Error**: Error message (expired, corrupted, unreachable)
|
||||
|
||||
### 5.4 Security summary (RFC §7.4)
|
||||
|
||||
Both upload-complete and download-ready states display a brief non-technical security summary:
|
||||
- Files are encrypted in the browser before upload — the server never sees file contents.
|
||||
- The link contains the decryption key in the hash fragment, which the browser never sends to any server.
|
||||
- For maximum security, use the SimpleX app.
|
||||
|
||||
### 5.5 File expiry
|
||||
|
||||
Display on upload-complete state: "Files are typically available for 48 hours." This is an approximation — actual expiry depends on each XFTP server's `[STORE_LOG]` retention configuration. The 48-hour figure matches the current preset server defaults.
|
||||
|
||||
### 5.6 Styling
|
||||
|
||||
Plain CSS, no framework. White background, centered content, responsive. Circular progress via `<canvas>` (arc drawing, percentage text in center).
|
||||
|
||||
File size limit: 100MB. Displayed on upload page.
|
||||
|
||||
### 5.7 CSP
|
||||
|
||||
`index.html` includes a `<meta>` Content-Security-Policy tag with a build-time placeholder:
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src __CSP_CONNECT_SRC__;">
|
||||
```
|
||||
|
||||
Vite's `transformIndexHtml` hook (in `vite.config.ts`) replaces `__CSP_CONNECT_SRC__` at build time with origins derived from the server list:
|
||||
- Local mode: `https://localhost:7000`
|
||||
- Production: `https://xftp1.simplex.im:443 https://xftp2.simplex.im:443 ...` (all 12 servers)
|
||||
|
||||
## 6. Upload Flow
|
||||
|
||||
`web/upload.ts`:
|
||||
|
||||
1. User drops/picks file → `File` object
|
||||
2. Validate `file.size <= 100 * 1024 * 1024` — show error if exceeded
|
||||
3. Read file: `new Uint8Array(await file.arrayBuffer())` — note: after `backend.encrypt()` transfers the buffer to the Worker, `fileData` is detached (zero-length). Peak memory is ~2× file size (main thread holds original until transfer, Worker holds encrypted copy before OPFS write). Acceptable for the 100MB limit; do not raise the limit without considering memory implications.
|
||||
4. Create `CryptoBackend` via factory
|
||||
5. Create `XFTPClientAgent`
|
||||
6. `backend.encrypt(fileData, file.name, onProgress)` → `EncryptResult`
|
||||
- Encryption progress shown on canvas (Worker posts progress messages)
|
||||
7. Pick one random server from configured list (V1: all chunks to same server)
|
||||
8. Call `uploadFile(agent, server, metadata, {onProgress, readChunk: (off, sz) => backend.readChunk(off, sz)})`:
|
||||
- `metadata` = `{digest, key, nonce, chunkSizes}` from EncryptResult
|
||||
- Network progress shown on canvas
|
||||
- Returns `{rcvDescription, sndDescription, uri}`
|
||||
9. Construct full URL: `window.location.origin + window.location.pathname + '#' + uri`
|
||||
10. Display link, copy button
|
||||
11. Cleanup: `backend.cleanup()`, `closeXFTPAgent(agent)`
|
||||
|
||||
**Cancel:** User can abort via cancel button. Sets an `AbortController` signal that:
|
||||
- Sends `{type: 'cleanup'}` to Worker
|
||||
- Closes the XFTPClientAgent (drops HTTP/2 connections)
|
||||
- Resets UI to landing state
|
||||
|
||||
## 7. Download Flow
|
||||
|
||||
`web/download.ts`:
|
||||
|
||||
1. Parse `window.location.hash.slice(1)` → `decodeDescriptionURI(fragment)` → `FileDescription`
|
||||
2. Display file size (`fd.size` bytes, formatted human-readable). Note: `fd.size` is the encrypted size (slightly larger than plaintext due to padding + auth tag). The plaintext size is not available until decryption — display it as an approximate file size. If `fd.redirect !== null`, size comes from `fd.redirect.size` (which is the inner encrypted size).
|
||||
3. User clicks "Download"
|
||||
4. Create `CryptoBackend` and `XFTPClientAgent`
|
||||
5. Call `downloadFileRaw(agent, fd, onRawChunk, {onProgress, concurrency: 3})`:
|
||||
- `onRawChunk` forwards each raw chunk to the Worker: `backend.decryptAndStoreChunk(raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo)`
|
||||
- `downloadFileRaw` handles redirect resolution internally (see §7.1), parallel downloads, and connection pooling
|
||||
- Returns the resolved `FileDescription` (inner fd for redirect case, original fd otherwise)
|
||||
6. `backend.verifyAndDecrypt({size: resolvedFd.size, digest: resolvedFd.digest, key: resolvedFd.key, nonce: resolvedFd.nonce})` → `{header, content}`
|
||||
- Verifies size + SHA-512 digest + file-level decryption inside Worker. Only the four needed fields are sent — private replica keys stay on the main thread.
|
||||
7. ACK: `ackFileChunks(agent, resolvedFd)` — best-effort, after verification succeeds
|
||||
8. Sanitize `header.fileName` before use: strip path separators (`/`, `\`), replace null/control characters (U+0000-U+001F, U+007F), strip Unicode bidi override characters (U+202A-U+202E, U+2066-U+2069 — prevents `doc.pdf.exe` appearing as `doc.exe.pdf`), limit length to 255 chars. The filename is user-controlled (set by the uploader) and arrives via decrypted content. Then trigger browser save: `new Blob([content])` → `<a download="${sanitizedName}">` click
|
||||
9. Cleanup: `backend.cleanup()`, `closeXFTPAgent(agent)`
|
||||
|
||||
### 7.1 Redirect handling
|
||||
|
||||
Handled inside `downloadFileRaw` in agent.ts — the web page doesn't see it. When `fd.redirect !== null`:
|
||||
|
||||
1. Download redirect chunks via `downloadXFTPChunkRaw` (parallel, same as regular chunks)
|
||||
2. Transit-decrypt + verify + file-level decrypt on main thread (redirect data is always small — a few KB of YAML, so main thread decryption is fine)
|
||||
3. Parse YAML → inner `FileDescription`, validate against `fd.redirect.{size, digest}`
|
||||
4. ACK redirect chunks (best-effort)
|
||||
5. Continue downloading inner description's chunks, calling `onRawChunk` for each
|
||||
|
||||
### 7.2 Architecture note: download refactoring
|
||||
|
||||
Both upload and download use `agent.ts` for orchestration. The key difference is where the crypto/network split happens:
|
||||
|
||||
- **Upload**: agent.ts reads encrypted chunks from the Worker via `readChunk` callback, sends them over the network.
|
||||
- **Download**: agent.ts receives raw encrypted responses from the network via `downloadXFTPChunkRaw` (DH key exchange + network only, no decryption), passes them to the web page via `onRawChunk` callback, which routes them to the Worker for transit decryption.
|
||||
|
||||
This split keeps all expensive crypto off the main thread. Transit decryption uses a custom JS Salsa20 implementation (`xorKeystream` in secretbox.ts) that would block the UI for ~50-200ms on a 4MB chunk. File-level decryption (`decryptChunks`) is similarly expensive. Both happen in the Worker.
|
||||
|
||||
The cheap operations stay on the main thread: DH key exchange (`generateX25519KeyPair` + `dh` — ~1ms via libsodium WASM), XFTP command encoding/decoding, connection management.
|
||||
|
||||
## 8. Build & Dev Setup
|
||||
|
||||
### 8.1 vite.config.ts (new, separate from vitest.config.ts)
|
||||
|
||||
```typescript
|
||||
import {defineConfig, type Plugin} from 'vite'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
import presets from './web/servers.json'
|
||||
|
||||
function parseHost(addr: string): string {
|
||||
const m = addr.match(/@(.+)$/)
|
||||
if (!m) throw new Error('bad server address: ' + addr)
|
||||
const host = m[1].split(',')[0]
|
||||
return host.includes(':') ? host : host + ':443'
|
||||
}
|
||||
|
||||
function cspPlugin(servers: string[]): Plugin {
|
||||
const origins = servers.map(s => 'https://' + parseHost(s)).join(' ')
|
||||
return {
|
||||
name: 'csp-connect-src',
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html, ctx) {
|
||||
if (ctx.server) {
|
||||
// Dev mode: remove CSP meta tag entirely — Vite HMR needs inline scripts
|
||||
return html.replace(/<meta\s[^>]*?Content-Security-Policy[\s\S]*?>/i, '')
|
||||
}
|
||||
return html.replace('__CSP_CONNECT_SRC__', origins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const define: Record<string, string> = {}
|
||||
let servers: string[]
|
||||
|
||||
if (mode === 'local') {
|
||||
const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
|
||||
const fp = createHash('sha256').update(der).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
servers = [`xftp://${fp}@localhost:7000`]
|
||||
define['__XFTP_SERVERS__'] = JSON.stringify(servers)
|
||||
} else {
|
||||
servers = [...presets.simplex, ...presets.flux]
|
||||
}
|
||||
|
||||
return {
|
||||
root: 'web',
|
||||
build: {outDir: '../dist-web'},
|
||||
define,
|
||||
worker: {format: 'es'},
|
||||
plugins: [cspPlugin(servers)],
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 8.2 package.json scripts
|
||||
|
||||
```json
|
||||
"dev": "vite --mode local",
|
||||
"build:local": "vite build --mode local",
|
||||
"build:prod": "vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"check:web": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.worker.json --noEmit"
|
||||
```
|
||||
|
||||
Note: `check:web` type-checks `src/` twice (once per config) — acceptable for this small library.
|
||||
|
||||
Add `vite` as an explicit devDependency (`^6.0.0` — matching the version vitest 3.x depends on transitively). Relying on transitive resolution is fragile across package managers.
|
||||
|
||||
### 8.3 TypeScript configuration
|
||||
|
||||
The existing `tsconfig.json` has `rootDir: "src"` and `include: ["src/**/*.ts"]` — this is for library compilation only (output to `dist/`). Vite handles `web/` TypeScript compilation independently via esbuild, so the main tsconfig is unchanged. `web/*.ts` files import from `../src/*.js` using relative paths.
|
||||
|
||||
Add two tsconfigs for `web/` type-checking — split by environment to avoid type pollution between DOM and WebWorker globals:
|
||||
|
||||
`tsconfig.web.json` — main-thread files (DOM globals: `document`, `window`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["web/**/*.ts", "src/**/*.ts"],
|
||||
"exclude": ["web/crypto.worker.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
`tsconfig.worker.json` — Worker file (`self`, `FileSystemSyncAccessHandle`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "WebWorker"]
|
||||
},
|
||||
"include": ["web/crypto.worker.ts", "src/**/*.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
Both configs set `"types": []` to prevent auto-inclusion of `@types/node` and `"moduleResolution": "bundler"` for Vite-compatible resolution (JSON imports, `.js` extension mapping). The base config's `"moduleResolution": "node"` would cause false type errors on `import ... from './servers.json'`. Both override `@types/node`, which would pollute DOM/WebWorker environments with Node.js globals (`process`, `Buffer`, etc.). This means `src/client.ts`'s dynamic `import("node:http2")` will produce a type error in these configs. This is acceptable — `src/client.ts` provides `createNodeTransport` which is never used in browser code (Vite tree-shakes it out), and full `src/` type-checking is handled by the base `tsconfig.json`. If the error is distracting, add `src/client.ts` to both configs' `exclude` arrays.
|
||||
|
||||
Both extend the library tsconfig (inheriting `strict`, `module`, etc.) and include `src/**/*.ts` so imports from `../src/*.js` resolve. `"noEmit": true` means they're only used for type-checking — Vite handles actual compilation. The inherited `"exclude": ["node_modules", "dist", "test"]` intentionally excludes `test/` — test files are type-checked by their own vitest/playwright configs, not by `check:web`.
|
||||
|
||||
### 8.4 Dev workflow
|
||||
|
||||
`npm run dev` → Vite dev server at `localhost:5173`, configured for local test server. Start `xftp-server` on port 7000 separately (or via the existing globalSetup).
|
||||
|
||||
Note: The CSP meta tag's `default-src 'self'` blocks Vite's injected HMR inline scripts in dev mode. The `cspPlugin` handles this by removing the entire CSP `<meta>` tag in serve mode (dev server), so HMR works without restrictions. Production builds always have the correct CSP.
|
||||
|
||||
## 9. Library Changes (agent.ts + client.ts)
|
||||
|
||||
Changes to support the web page: upload `readChunk` callback, download `onRawChunk` callback with parallel chunk downloads.
|
||||
|
||||
### 9.1 Type changes
|
||||
|
||||
Split the existing `EncryptedFileInfo` (which currently has `encData`, `digest`, `key`, `nonce`, `chunkSizes` as direct fields) into a metadata-only base and an extension:
|
||||
|
||||
```typescript
|
||||
// Metadata-only variant (no encData — data lives in Worker/OPFS)
|
||||
export interface EncryptedFileMetadata {
|
||||
digest: Uint8Array
|
||||
key: Uint8Array
|
||||
nonce: Uint8Array
|
||||
chunkSizes: number[]
|
||||
}
|
||||
|
||||
// Full variant (existing, extends metadata with data)
|
||||
export interface EncryptedFileInfo extends EncryptedFileMetadata {
|
||||
encData: Uint8Array
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 uploadFile signature change
|
||||
|
||||
Replace positional optional params with an options bag. Add optional `readChunk`. When provided, `encrypted.encData` is not accessed.
|
||||
|
||||
```typescript
|
||||
export interface UploadOptions {
|
||||
onProgress?: (uploaded: number, total: number) => void
|
||||
redirectThreshold?: number
|
||||
readChunk?: (offset: number, size: number) => Promise<Uint8Array>
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
encrypted: EncryptedFileMetadata,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResult>
|
||||
```
|
||||
|
||||
Inside `uploadFile`:
|
||||
- Chunk read: if `options?.readChunk` is provided, use it. Otherwise, verify `'encData' in encrypted` at runtime (throws `"uploadFile: readChunk required when encData is absent"` if missing), then use `(off, sz) => Promise.resolve((encrypted as EncryptedFileInfo).encData.subarray(off, off + sz))`. This guards against calling `uploadFile` with `EncryptedFileMetadata` but no `readChunk`. For each chunk, call `readChunk(offset, size)` once and use the returned `Uint8Array` for both `getChunkDigest(chunkData)` and `uploadXFTPChunk(..., chunkData)` — do not call `readChunk` twice per chunk.
|
||||
- Progress total: `const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)` — replaces `encrypted.encData.length` (line 129) since `EncryptedFileMetadata` has no `encData`. The values are identical: `encData.length === sum(chunkSizes)`.
|
||||
- `buildDescription` parameter type: change from `EncryptedFileInfo` to `EncryptedFileMetadata` — it only accesses `chunkSizes`, `digest`, `key`, `nonce` (not `encData`).
|
||||
|
||||
`uploadRedirectDescription` (internal) is unchanged — redirect descriptions are always small and created in-memory by `encryptFileForUpload`.
|
||||
|
||||
### 9.3 Backward compatibility
|
||||
|
||||
The signature change from positional params `(agent, server, encrypted, onProgress?, redirectThreshold?)` to `(agent, server, encrypted, options?)` is a breaking change for callers that pass `onProgress` or `redirectThreshold`. In practice, the only callers are the browser test (which passes no options — no change needed) and the web page (new code). `EncryptedFileInfo` extends `EncryptedFileMetadata`, so existing callers that pass `EncryptedFileInfo` work without change.
|
||||
|
||||
### 9.4 client.ts: downloadXFTPChunkRaw
|
||||
|
||||
Split `downloadXFTPChunk` at the network/crypto boundary. The new function does DH key exchange and network I/O but skips transit decryption:
|
||||
|
||||
```typescript
|
||||
export interface RawChunkResponse {
|
||||
dhSecret: Uint8Array
|
||||
nonce: Uint8Array
|
||||
body: Uint8Array
|
||||
}
|
||||
|
||||
export async function downloadXFTPChunkRaw(
|
||||
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array
|
||||
): Promise<RawChunkResponse> {
|
||||
const {publicKey, privateKey} = generateX25519KeyPair()
|
||||
const cmd = encodeFGET(encodePubKeyX25519(publicKey))
|
||||
const {response, body} = await sendXFTPCommand(c, rpKey, fId, cmd)
|
||||
if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
|
||||
const dhSecret = dh(response.rcvDhKey, privateKey)
|
||||
return {dhSecret, nonce: response.nonce, body}
|
||||
}
|
||||
```
|
||||
|
||||
`RawChunkResponse` contains only what client.ts produces (DH secret, nonce, encrypted body). The chunk metadata (`chunkNo`, `digest`) is added by agent.ts when constructing `RawDownloadedChunk` (see §9.5).
|
||||
|
||||
The existing `downloadXFTPChunk` is refactored to call `downloadXFTPChunkRaw` + `decryptReceivedChunk`:
|
||||
|
||||
```typescript
|
||||
export async function downloadXFTPChunk(
|
||||
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(c, rpKey, fId)
|
||||
return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
|
||||
}
|
||||
```
|
||||
|
||||
### 9.5 agent.ts: downloadFileRaw, ackFileChunks, RawDownloadedChunk
|
||||
|
||||
New type combining client.ts's `RawChunkResponse` with chunk metadata from agent.ts:
|
||||
|
||||
```typescript
|
||||
export interface RawDownloadedChunk {
|
||||
chunkNo: number
|
||||
dhSecret: Uint8Array
|
||||
nonce: Uint8Array
|
||||
body: Uint8Array
|
||||
digest: Uint8Array
|
||||
}
|
||||
```
|
||||
|
||||
New function providing download orchestration with a raw chunk callback. Handles connection pooling, parallel downloads, redirect resolution, and progress. Does **not** ACK — the caller ACKs after verification.
|
||||
|
||||
```typescript
|
||||
export interface DownloadRawOptions {
|
||||
onProgress?: (downloaded: number, total: number) => void
|
||||
concurrency?: number // max parallel chunk downloads, default 1
|
||||
}
|
||||
|
||||
export async function downloadFileRaw(
|
||||
agent: XFTPClientAgent,
|
||||
fd: FileDescription,
|
||||
onRawChunk: (chunk: RawDownloadedChunk) => Promise<void>,
|
||||
options?: DownloadRawOptions
|
||||
): Promise<FileDescription>
|
||||
```
|
||||
|
||||
Returns the resolved `FileDescription` — for redirect files this is the inner fd, for non-redirect files this is the original fd. The caller uses this for verification and ACK.
|
||||
|
||||
Internal structure:
|
||||
|
||||
1. Validate `fd` via `validateFileDescription` (may double-validate if caller already validated via `decodeDescriptionURI` — harmless)
|
||||
2. If `fd.redirect !== null`: resolve redirect on main thread (redirect data is small):
|
||||
a. Download redirect chunks via `downloadXFTPChunk` (not raw — main thread decryption is fine for a few KB)
|
||||
b. Verify size + digest, `processDownloadedFile` → YAML bytes
|
||||
c. Parse inner `FileDescription`, validate against `fd.redirect.{size, digest}`
|
||||
d. ACK redirect chunks (best-effort — redirect chunks are small and separate from the file chunks)
|
||||
e. Replace `fd` with inner description
|
||||
3. Pre-connect: call `getXFTPServerClient(agent, server)` for each unique server before launching concurrent workers. This ensures the client connection exists in the agent's map, avoiding a race condition where multiple concurrent workers all see the client as missing and each call `connectXFTP` independently (leaking all but the last connection). Known limitation: if a connection drops mid-download and multiple workers attempt reconnection simultaneously, the same TOCTOU race reappears. This is a pre-existing issue in `getXFTPServerClient`; a proper fix (per-key connection promise) is out of scope for this plan but should be tracked for follow-up.
|
||||
4. Download file chunks in parallel (concurrency-limited via sliding window):
|
||||
- Create a queue of chunk indices `[0, 1, ..., N-1]`. Launch `min(concurrency, N)` async workers, each pulling the next index from the queue until empty. Each worker loops: pull index → derive key → `getXFTPServerClient` → `downloadXFTPChunkRaw` → `await onRawChunk(...)` → update progress → next index. `await Promise.all(workers)` to wait for completion.
|
||||
- For each chunk: derive key (`decodePrivKeyEd25519` → `ed25519KeyPairFromSeed`), get client (`getXFTPServerClient`), call `downloadXFTPChunkRaw`, `await onRawChunk(...)` with result + `chunkNo` + `chunk.digest`
|
||||
- Each concurrency slot awaits its `onRawChunk` before starting the next download on that slot. With `concurrency > 1`, multiple `onRawChunk` calls may be in-flight concurrently (one per slot). The Worker handles this correctly — messages are queued and processed sequentially.
|
||||
- Update progress after each chunk: `downloaded += chunk.chunkSize; onProgress?.(downloaded, resolvedFd.size)` — both values use encrypted sizes for consistency
|
||||
5. Return the resolved `fd`
|
||||
|
||||
New helper for ACKing after verification:
|
||||
|
||||
```typescript
|
||||
export async function ackFileChunks(
|
||||
agent: XFTPClientAgent, fd: FileDescription
|
||||
): Promise<void> {
|
||||
for (const chunk of fd.chunks) {
|
||||
const replica = chunk.replicas[0]
|
||||
if (!replica) continue
|
||||
try {
|
||||
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
|
||||
const seed = decodePrivKeyEd25519(replica.replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The existing `downloadFile` is refactored to use `downloadFileRaw` internally:
|
||||
|
||||
```typescript
|
||||
export async function downloadFile(
|
||||
agent: XFTPClientAgent,
|
||||
fd: FileDescription,
|
||||
onProgress?: (downloaded: number, total: number) => void
|
||||
): Promise<DownloadResult> {
|
||||
const chunks: Uint8Array[] = []
|
||||
const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
|
||||
chunks[raw.chunkNo - 1] = decryptReceivedChunk(
|
||||
raw.dhSecret, raw.nonce, raw.body, raw.digest
|
||||
)
|
||||
}, {onProgress})
|
||||
// verify + file-level decrypt using resolvedFd (inner fd for redirect case)
|
||||
const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
|
||||
if (combined.length !== resolvedFd.size) throw new Error("downloadFile: file size mismatch")
|
||||
const digest = sha512(combined)
|
||||
if (!digestEqual(digest, resolvedFd.digest)) throw new Error("downloadFile: file digest mismatch")
|
||||
// processDownloadedFile re-concatenates chunks internally — this mirrors the
|
||||
// existing downloadFile pattern (verify on concatenated data, then pass chunks
|
||||
// array to decryptChunks which concatenates again). Acceptable overhead for
|
||||
// correctness: verification must happen on transit-decrypted data before
|
||||
// file-level decryption transforms it.
|
||||
const result = processDownloadedFile(resolvedFd, chunks)
|
||||
await ackFileChunks(agent, resolvedFd)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Existing callers retain serial behavior (`concurrency` defaults to 1). The web page opts into parallelism by passing `concurrency: 3`. The browser test (`test/browser.test.ts`) continues to work unchanged. The chunks array is initialized empty (`[]`) and populated by sparse index assignment (`chunks[raw.chunkNo - 1] = ...`), so it correctly handles both redirect and non-redirect cases regardless of the outer fd's chunk count. `digestEqual` is an existing module-private helper in agent.ts (line 327) that performs constant-time byte comparison.
|
||||
|
||||
### 9.6 Backward compatibility (download)
|
||||
|
||||
`downloadFile` signature is unchanged — existing callers are unaffected. The refactoring adds `downloadFileRaw`, `ackFileChunks`, and `RawDownloadedChunk` as new exports from agent.ts, and `downloadXFTPChunkRaw` + `RawChunkResponse` as new exports from client.ts.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 Existing tests (unchanged)
|
||||
|
||||
- `npm run test:browser` — vitest browser round-trip (library-level)
|
||||
- `cabal test --test-option='--match=/XFTP Web Client/'` — Haskell per-function tests
|
||||
|
||||
### 10.2 New: page E2E test
|
||||
|
||||
Add `test/page.spec.ts` using `@playwright/test` (not vitest browser mode — vitest tests run IN the browser and can't control page navigation; Playwright tests run in Node.js and control the browser). Add `@playwright/test` as a devDependency.
|
||||
|
||||
Add `playwright.config.ts` at the project root (`xftp-web/`):
|
||||
- `webServer: { command: 'vite build --mode local && vite preview', url: 'http://localhost:4173', reuseExistingServer: !process.env.CI }` — the `url` property tells Playwright to wait until the preview server is ready before running tests
|
||||
- `use.ignoreHTTPSErrors: true` (test server uses self-signed cert)
|
||||
- `use.launchOptions: { args: ['--ignore-certificate-errors'] }` — required because Playwright's `ignoreHTTPSErrors` only affects page navigation, not `fetch()` calls from in-page JavaScript. Without this flag, the page's `createBrowserTransport` fetch to `https://localhost:7000` would fail TLS validation.
|
||||
- `globalSetup`: `'./test/globalSetup.ts'` (starts xftp-server, shared with vitest)
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@playwright/test'
|
||||
|
||||
test('page upload + download round-trip', async ({page}) => {
|
||||
await page.goto(PAGE_URL)
|
||||
// Set file input via page.setInputFiles()
|
||||
// Wait for upload link to appear: page.waitForSelector('[data-testid="share-link"]')
|
||||
// Extract hash from link text
|
||||
// Navigate to PAGE_URL + '#' + hash
|
||||
// Wait for download complete state
|
||||
// Verify file was offered for save (check download event)
|
||||
})
|
||||
```
|
||||
|
||||
Add script: `"test:page": "playwright test test/page.spec.ts"`
|
||||
|
||||
This tests the real bundle including Worker loading, OPFS, and CSP. The existing `test/browser.test.ts` continues to test the library-level pipeline (vitest browser mode, no Workers).
|
||||
|
||||
### 10.3 Manual testing
|
||||
|
||||
`npm run dev` → open `localhost:5173` in browser → drag file → get link → open link in new tab → download. Requires xftp-server running on port 7000 (local mode).
|
||||
|
||||
## 11. Files
|
||||
|
||||
**Create:**
|
||||
- `xftp-web/web/index.html` — page entry point (includes CSP meta tag)
|
||||
- `xftp-web/web/main.ts` — router + libsodium init
|
||||
- `xftp-web/web/upload.ts` — upload UI + orchestration
|
||||
- `xftp-web/web/download.ts` — download UI + orchestration
|
||||
- `xftp-web/web/progress.ts` — circular progress canvas component
|
||||
- `xftp-web/web/servers.json` — preset server addresses (shared by servers.ts and vite.config.ts)
|
||||
- `xftp-web/web/servers.ts` — server configuration (imports servers.json)
|
||||
- `xftp-web/web/crypto-backend.ts` — CryptoBackend interface + WorkerBackend + factory
|
||||
- `xftp-web/web/crypto.worker.ts` — Web Worker implementation
|
||||
- `xftp-web/web/style.css` — styles
|
||||
- `xftp-web/vite.config.ts` — page build config (CSP generation, server list)
|
||||
- `xftp-web/tsconfig.web.json` — IDE/CI type-checking for `web/` main-thread files (DOM)
|
||||
- `xftp-web/tsconfig.worker.json` — IDE/CI type-checking for `web/crypto.worker.ts` (WebWorker)
|
||||
- `xftp-web/playwright.config.ts` — Playwright E2E test config (webServer, globalSetup)
|
||||
- `xftp-web/test/page.spec.ts` — page E2E test (Playwright)
|
||||
|
||||
**Modify:**
|
||||
- `xftp-web/src/agent.ts` — add `EncryptedFileMetadata` type, `uploadFile` options bag with `readChunk`, `downloadFileRaw` with `onRawChunk` callback + parallel downloads, `ackFileChunks`, `RawDownloadedChunk` type, refactor `downloadFile` on top of `downloadFileRaw`, add `import {decryptReceivedChunk} from "./download.js"` (needed by refactored `downloadFile`)
|
||||
- `xftp-web/src/client.ts` — add `downloadXFTPChunkRaw`, `RawChunkResponse` type, refactor `downloadXFTPChunk` to use raw variant
|
||||
- `xftp-web/package.json` — add dev/build/check:web/test:page scripts, add `vite` + `@playwright/test` devDeps
|
||||
- `xftp-web/src/protocol/description.ts` — fix stale "SHA-256" comment on `FileDescription.digest` to "SHA-512"
|
||||
- `xftp-web/.gitignore` — add `dist-web/`
|
||||
|
||||
## 12. Implementation Order
|
||||
|
||||
1. **Library refactoring** — `client.ts`: add `downloadXFTPChunkRaw`; `agent.ts`: add `downloadFileRaw` + parallel downloads, `uploadFile` options bag with `readChunk`; refactor existing `downloadFile` on top of `downloadFileRaw`. Run existing tests to verify no regressions.
|
||||
2. **Vite config + HTML shell** — `vite.config.ts`, `index.html`, `main.ts`, verify dev server works
|
||||
3. **Server config** — `servers.ts` with both local and production server lists
|
||||
4. **CryptoBackend + Worker** — interface, WorkerBackend, Worker implementation, OPFS logic
|
||||
5. **Upload flow** — `upload.ts` with drag-drop, encrypt via Worker, upload via agent, show link
|
||||
6. **Download flow** — `download.ts` with URL parsing, download via agent `downloadFileRaw`, Worker decrypt, browser save
|
||||
7. **Progress component** — `progress.ts` canvas drawing
|
||||
8. **Styling** — `style.css`
|
||||
9. **Testing** — page E2E test, manual browser verification
|
||||
10. **Build scripts** — `build:local`, `build:prod` in package.json
|
||||
@@ -0,0 +1,53 @@
|
||||
# XFTPClientAgent Pattern
|
||||
|
||||
## TOC
|
||||
1. Executive Summary
|
||||
2. Changes: client.ts
|
||||
3. Changes: agent.ts
|
||||
4. Changes: test/browser.test.ts
|
||||
5. Verification
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Add `XFTPClientAgent` — a per-server connection pool matching the Haskell pattern. The agent caches `XFTPClient` instances by server URL. All orchestration functions (`uploadFile`, `downloadFile`, `deleteFile`) take `agent` as first parameter and use `getXFTPServerClient(agent, server)` instead of calling `connectXFTP` directly. Connections stay open on success; the caller creates and closes the agent.
|
||||
|
||||
`connectXFTP` and `closeXFTP` stay exported (used by `XFTPWebTests.hs` Haskell tests). The `browserClients` hack, per-function `connections: Map`, and `getOrConnect` are deleted.
|
||||
|
||||
## Changes: client.ts
|
||||
|
||||
**Add** after types section: `XFTPClientAgent` interface, `newXFTPAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `closeXFTPAgent`.
|
||||
|
||||
**Delete**: `browserClients` Map and all `isNode` browser-cache checks in `connectXFTP` and `closeXFTP`.
|
||||
|
||||
**Revert `closeXFTP`** to unconditional `c.transport.close()` (browser transport.close() is already a no-op).
|
||||
|
||||
`connectXFTP` stays exported (backward compat) but becomes a raw low-level function — no caching.
|
||||
|
||||
## Changes: agent.ts
|
||||
|
||||
**Imports**: replace `connectXFTP`/`closeXFTP` with `getXFTPServerClient`/`closeXFTPAgent` etc.
|
||||
|
||||
**Re-export** from agent.ts: `newXFTPAgent`, `closeXFTPAgent`, `XFTPClientAgent`.
|
||||
|
||||
**`uploadFile`**: add `agent: XFTPClientAgent` as first param. Replace `connectXFTP` → `getXFTPServerClient`. Remove `finally { closeXFTP }`. Pass `agent` to `uploadRedirectDescription`.
|
||||
|
||||
**`uploadRedirectDescription`**: change from `(client, server, innerFd)` to `(agent, server, innerFd)`. Get client via `getXFTPServerClient`.
|
||||
|
||||
**`downloadFile`**: add `agent` param. Delete local `connections: Map`. Replace `getOrConnect` → `getXFTPServerClient`. Remove finally cleanup. Pass `agent` to `downloadWithRedirect`.
|
||||
|
||||
**`downloadWithRedirect`**: add `agent` param. Same replacements. Remove try/catch cleanup. Recursive call passes `agent`.
|
||||
|
||||
**`deleteFile`**: add `agent` param. Same pattern.
|
||||
|
||||
**Delete**: `getOrConnect` function entirely.
|
||||
|
||||
## Changes: test/browser.test.ts
|
||||
|
||||
Create agent before operations, pass to upload/download, close in finally.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npx vitest --run` — browser round-trip test passes
|
||||
2. No remaining `browserClients`, `getOrConnect`, or per-function `connections: Map` locals
|
||||
3. `connectXFTP` and `closeXFTP` still exported (XFTPWebTests.hs compat)
|
||||
4. All orchestration functions take `agent` as first param
|
||||
@@ -0,0 +1,859 @@
|
||||
# XFTP Web Page E2E Tests Plan
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Test Infrastructure](#2-test-infrastructure)
|
||||
3. [Test Infrastructure - Page Objects](#3-test-infrastructure---page-objects)
|
||||
4. [Upload Flow Tests](#4-upload-flow-tests)
|
||||
5. [Download Flow Tests](#5-download-flow-tests)
|
||||
6. [Edge Cases](#6-edge-cases)
|
||||
7. [Implementation Order](#7-implementation-order)
|
||||
8. [Test Utilities](#8-test-utilities)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This document specifies comprehensive Playwright E2E tests for the XFTP web page. The existing test (`page.spec.ts`) performs a basic upload/download round-trip. This plan extends coverage to:
|
||||
|
||||
- **Upload flow**: File selection (picker + drag-drop), validation, progress, cancellation, link sharing, error handling
|
||||
- **Download flow**: Invalid link handling, download button, progress, file save, error states
|
||||
- **Edge cases**: Boundary file sizes, special characters, network failures, multi-chunk files with redirect, UI information display
|
||||
|
||||
**Key constraints**:
|
||||
- Tests run against a local XFTP server (started via `globalSetup.ts`)
|
||||
- Server port is dynamic (read from `/tmp/xftp-test-server.port`)
|
||||
- Browser uses `--ignore-certificate-errors` for self-signed certs
|
||||
- OPFS and Web Workers are required (Chromium supports both)
|
||||
|
||||
**Test file location**: `/code/simplexmq/xftp-web/test/page.spec.ts`
|
||||
|
||||
**Architecture**: Tests use the Page Object Model pattern to encapsulate UI interactions, making tests read as domain-specific scenarios rather than raw Playwright API calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Infrastructure
|
||||
|
||||
### 2.1 Current Setup
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── playwright.config.ts # Playwright config (webServer, globalSetup)
|
||||
├── test/
|
||||
│ ├── globalSetup.ts # Starts xftp-server, writes port to PORT_FILE
|
||||
│ ├── page.spec.ts # E2E tests (to be extended)
|
||||
│ └── pages/ # Page Objects (new)
|
||||
│ ├── UploadPage.ts
|
||||
│ └── DownloadPage.ts
|
||||
```
|
||||
|
||||
### 2.2 Prerequisites
|
||||
|
||||
- `globalSetup.ts` starts the XFTP server and writes port to `PORT_FILE`
|
||||
- Tests must read the port dynamically: `readFileSync(PORT_FILE, 'utf-8').trim()`
|
||||
- Vite builds and serves the page at `http://localhost:4173`
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Infrastructure - Page Objects
|
||||
|
||||
Page Objects encapsulate page-specific selectors and actions, providing a clean API for tests. This follows the standard Page Object Model pattern used in simplex-chat and most professional test suites.
|
||||
|
||||
### 3.1 UploadPage
|
||||
|
||||
```typescript
|
||||
// test/pages/UploadPage.ts
|
||||
import {Page, Locator, expect} from '@playwright/test'
|
||||
|
||||
export class UploadPage {
|
||||
readonly page: Page
|
||||
readonly dropZone: Locator
|
||||
readonly fileInput: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly cancelButton: Locator
|
||||
readonly completeStage: Locator
|
||||
readonly shareLink: Locator
|
||||
readonly copyButton: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly expiryNote: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.dropZone = page.locator('#drop-zone')
|
||||
this.fileInput = page.locator('#file-input')
|
||||
this.progressStage = page.locator('#upload-progress')
|
||||
this.progressCanvas = page.locator('#progress-container canvas')
|
||||
this.statusText = page.locator('#upload-status')
|
||||
this.cancelButton = page.locator('#cancel-btn')
|
||||
this.completeStage = page.locator('#upload-complete')
|
||||
this.shareLink = page.locator('[data-testid="share-link"]')
|
||||
this.copyButton = page.locator('#copy-btn')
|
||||
this.errorStage = page.locator('#upload-error')
|
||||
this.errorMessage = page.locator('#error-msg')
|
||||
this.retryButton = page.locator('#retry-btn')
|
||||
this.expiryNote = page.locator('.expiry')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('http://localhost:4173')
|
||||
}
|
||||
|
||||
async selectFile(name: string, content: Buffer, mimeType = 'application/octet-stream') {
|
||||
await this.fileInput.setInputFiles({name, mimeType, buffer: content})
|
||||
}
|
||||
|
||||
async selectTextFile(name: string, content: string) {
|
||||
await this.selectFile(name, Buffer.from(content, 'utf-8'), 'text/plain')
|
||||
}
|
||||
|
||||
async selectLargeFile(name: string, sizeBytes: number) {
|
||||
// Create large file in browser to avoid memory issues in test process
|
||||
await this.page.evaluate(({name, size}) => {
|
||||
const input = document.getElementById('file-input') as HTMLInputElement
|
||||
const buffer = new ArrayBuffer(size)
|
||||
new Uint8Array(buffer).fill(0x55)
|
||||
const file = new File([buffer], name, {type: 'application/octet-stream'})
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(file)
|
||||
input.files = dt.files
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}))
|
||||
}, {name, size: sizeBytes})
|
||||
}
|
||||
|
||||
async dragDropFile(name: string, content: Buffer) {
|
||||
// Drag-drop uses same file input handler internally
|
||||
await this.selectFile(name, content)
|
||||
}
|
||||
|
||||
async waitForEncrypting(timeout = 10_000) {
|
||||
await expect(this.statusText).toContainText('Encrypting', {timeout})
|
||||
}
|
||||
|
||||
async waitForUploading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Uploading', {timeout})
|
||||
}
|
||||
|
||||
async waitForShareLink(timeout = 60_000): Promise<string> {
|
||||
await expect(this.shareLink).toBeVisible({timeout})
|
||||
return await this.shareLink.inputValue()
|
||||
}
|
||||
|
||||
async clickCopy() {
|
||||
await this.copyButton.click()
|
||||
await expect(this.copyButton).toContainText('Copied!')
|
||||
}
|
||||
|
||||
async clickCancel() {
|
||||
await this.cancelButton.click()
|
||||
}
|
||||
|
||||
async clickRetry() {
|
||||
await this.retryButton.click()
|
||||
}
|
||||
|
||||
async expectError(messagePattern: string | RegExp) {
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectDropZoneVisible() {
|
||||
await expect(this.dropZone).toBeVisible()
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectCompleteWithExpiry() {
|
||||
await expect(this.completeStage).toBeVisible()
|
||||
await expect(this.expiryNote).toContainText('48 hours')
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
|
||||
getHashFromLink(url: string): string {
|
||||
return new URL(url).hash
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 DownloadPage
|
||||
|
||||
```typescript
|
||||
// test/pages/DownloadPage.ts
|
||||
import {Page, Locator, expect, Download} from '@playwright/test'
|
||||
|
||||
export class DownloadPage {
|
||||
readonly page: Page
|
||||
readonly readyStage: Locator
|
||||
readonly downloadButton: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.readyStage = page.locator('#dl-ready')
|
||||
this.downloadButton = page.locator('#dl-btn')
|
||||
this.progressStage = page.locator('#dl-progress')
|
||||
this.progressCanvas = page.locator('#dl-progress-container canvas')
|
||||
this.statusText = page.locator('#dl-status')
|
||||
this.errorStage = page.locator('#dl-error')
|
||||
this.errorMessage = page.locator('#dl-error-msg')
|
||||
this.retryButton = page.locator('#dl-retry-btn')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto(hash: string) {
|
||||
await this.page.goto(`http://localhost:4173${hash}`)
|
||||
}
|
||||
|
||||
async gotoWithLink(fullUrl: string) {
|
||||
const hash = new URL(fullUrl).hash
|
||||
await this.goto(hash)
|
||||
}
|
||||
|
||||
async expectFileReady() {
|
||||
await expect(this.readyStage).toBeVisible()
|
||||
await expect(this.downloadButton).toBeVisible()
|
||||
}
|
||||
|
||||
async expectFileSizeDisplayed() {
|
||||
await expect(this.readyStage).toContainText(/\d+(?:\.\d+)?\s*(?:KB|MB|B)/)
|
||||
}
|
||||
|
||||
async clickDownload(): Promise<Download> {
|
||||
const downloadPromise = this.page.waitForEvent('download')
|
||||
await this.downloadButton.click()
|
||||
return downloadPromise
|
||||
}
|
||||
|
||||
async waitForDownloading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Downloading', {timeout})
|
||||
}
|
||||
|
||||
async waitForDecrypting(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Decrypting', {timeout})
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectInitialError(messagePattern: string | RegExp) {
|
||||
// For malformed links - error shown in card without #dl-error stage
|
||||
await expect(this.page.locator('.card .error')).toBeVisible()
|
||||
await expect(this.page.locator('.card .error')).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectRuntimeError(messagePattern: string | RegExp) {
|
||||
// For runtime download errors - uses #dl-error stage
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Test Fixtures
|
||||
|
||||
```typescript
|
||||
// test/fixtures.ts
|
||||
import {test as base} from '@playwright/test'
|
||||
import {UploadPage} from './pages/UploadPage'
|
||||
import {DownloadPage} from './pages/DownloadPage'
|
||||
import {readFileSync} from 'fs'
|
||||
|
||||
// Extend Playwright test with page objects
|
||||
export const test = base.extend<{
|
||||
uploadPage: UploadPage
|
||||
downloadPage: DownloadPage
|
||||
}>({
|
||||
uploadPage: async ({page}, use) => {
|
||||
const uploadPage = new UploadPage(page)
|
||||
await uploadPage.goto()
|
||||
await use(uploadPage)
|
||||
},
|
||||
downloadPage: async ({page}, use) => {
|
||||
await use(new DownloadPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export {expect} from '@playwright/test'
|
||||
|
||||
// Test data helpers
|
||||
export function createTestContent(size: number, fill = 0x41): Buffer {
|
||||
return Buffer.alloc(size, fill)
|
||||
}
|
||||
|
||||
export function createTextContent(text: string): Buffer {
|
||||
return Buffer.from(text, 'utf-8')
|
||||
}
|
||||
|
||||
export function uniqueFileName(base: string, ext = 'txt'): string {
|
||||
return `${base}-${Date.now()}.${ext}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Upload Flow Tests
|
||||
|
||||
### 4.1 File Selection - File Picker Button
|
||||
|
||||
**Test ID**: `upload-file-picker`
|
||||
|
||||
```typescript
|
||||
test('upload via file picker button', async ({uploadPage}) => {
|
||||
await uploadPage.expectDropZoneVisible()
|
||||
|
||||
await uploadPage.selectTextFile('picker-test.txt', 'test content ' + Date.now())
|
||||
await uploadPage.waitForEncrypting()
|
||||
await uploadPage.waitForUploading()
|
||||
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toMatch(/^http:\/\/localhost:\d+\/#/)
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 File Selection - Drag and Drop
|
||||
|
||||
**Test ID**: `upload-drag-drop`
|
||||
|
||||
```typescript
|
||||
test('upload via drag and drop', async ({uploadPage}) => {
|
||||
await uploadPage.dragDropFile('dragdrop-test.txt', createTextContent('drag drop test'))
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toContain('#')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 File Size Validation - Too Large
|
||||
|
||||
**Test ID**: `upload-file-too-large`
|
||||
|
||||
```typescript
|
||||
test('upload rejects file over 100MB', async ({uploadPage}) => {
|
||||
await uploadPage.selectLargeFile('large.bin', 100 * 1024 * 1024 + 1)
|
||||
await uploadPage.expectError('too large')
|
||||
await uploadPage.expectError('100 MB')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.4 File Size Validation - Empty File
|
||||
|
||||
**Test ID**: `upload-file-empty`
|
||||
|
||||
```typescript
|
||||
test('upload rejects empty file', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('empty.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.5 Progress Display
|
||||
|
||||
**Test ID**: `upload-progress-display`
|
||||
|
||||
```typescript
|
||||
test('upload shows progress during encryption and upload', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('progress-test.bin', createTestContent(500 * 1024))
|
||||
|
||||
await uploadPage.expectProgressVisible()
|
||||
await uploadPage.waitForEncrypting()
|
||||
await uploadPage.waitForUploading()
|
||||
await uploadPage.waitForShareLink()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.6 Cancel Button
|
||||
|
||||
**Test ID**: `upload-cancel`
|
||||
|
||||
```typescript
|
||||
test('cancel button aborts upload and returns to landing', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('cancel-test.bin', createTestContent(1024 * 1024))
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
await uploadPage.clickCancel()
|
||||
|
||||
await uploadPage.expectDropZoneVisible()
|
||||
await expect(uploadPage.shareLink).toBeHidden()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.7 Share Link Display and Copy
|
||||
|
||||
**Test ID**: `upload-share-link-copy`
|
||||
|
||||
```typescript
|
||||
test('share link copy button works', async ({uploadPage, context}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
|
||||
await uploadPage.selectTextFile('copy-test.txt', 'copy test content')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await uploadPage.clickCopy()
|
||||
|
||||
// Verify clipboard (may fail in headless)
|
||||
try {
|
||||
const clipboardText = await uploadPage.page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(clipboardText).toBe(link)
|
||||
} catch {
|
||||
// Clipboard API may not be available
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4.8 Error Handling and Retry
|
||||
|
||||
**Test ID**: `upload-error-retry`
|
||||
|
||||
```typescript
|
||||
test('error state shows retry button', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('error-test.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
await expect(uploadPage.retryButton).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Download Flow Tests
|
||||
|
||||
### 5.1 Invalid Link Handling - Malformed Hash
|
||||
|
||||
**Test ID**: `download-invalid-hash-malformed`
|
||||
|
||||
```typescript
|
||||
test('download shows error for malformed hash', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#not-valid-base64!!!')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
await expect(downloadPage.downloadButton).not.toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### 5.2 Invalid Link Handling - Valid Base64 but Invalid Structure
|
||||
|
||||
**Test ID**: `download-invalid-hash-structure`
|
||||
|
||||
```typescript
|
||||
test('download shows error for invalid structure', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#AAAA')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 Download Button Click
|
||||
|
||||
**Test ID**: `download-button-click`
|
||||
|
||||
```typescript
|
||||
test('download button initiates download', async ({uploadPage, downloadPage}) => {
|
||||
// Upload first
|
||||
await uploadPage.selectTextFile('dl-btn-test.txt', 'download test content')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
// Navigate to download
|
||||
await downloadPage.gotoWithLink(link)
|
||||
await downloadPage.expectFileReady()
|
||||
|
||||
// Click download
|
||||
const download = await downloadPage.clickDownload()
|
||||
expect(download.suggestedFilename()).toBe('dl-btn-test.txt')
|
||||
})
|
||||
```
|
||||
|
||||
### 5.4 Progress Display
|
||||
|
||||
**Test ID**: `download-progress-display`
|
||||
|
||||
```typescript
|
||||
test('download shows progress', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('dl-progress.bin', createTestContent(500 * 1024))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const downloadPromise = downloadPage.clickDownload()
|
||||
|
||||
await downloadPage.expectProgressVisible()
|
||||
await downloadPage.waitForDownloading()
|
||||
|
||||
await downloadPromise
|
||||
})
|
||||
```
|
||||
|
||||
### 5.5 File Save Verification
|
||||
|
||||
**Test ID**: `download-file-save`
|
||||
|
||||
```typescript
|
||||
test('downloaded file content matches upload', async ({uploadPage, downloadPage}) => {
|
||||
const content = 'verification content ' + Date.now()
|
||||
const fileName = 'verify.txt'
|
||||
|
||||
await uploadPage.selectTextFile(fileName, content)
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const downloadedContent = (await import('fs')).readFileSync(path, 'utf-8')
|
||||
expect(downloadedContent).toBe(content)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases
|
||||
|
||||
### 6.1 Very Small Files
|
||||
|
||||
**Test ID**: `edge-small-file`
|
||||
|
||||
```typescript
|
||||
test('upload and download 1-byte file', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('tiny.bin', Buffer.from([0x42]))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe('tiny.bin')
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const content = (await import('fs')).readFileSync(path)
|
||||
expect(content.length).toBe(1)
|
||||
expect(content[0]).toBe(0x42)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.2 Files Near 100MB Limit
|
||||
|
||||
**Test ID**: `edge-near-limit`
|
||||
|
||||
```typescript
|
||||
test.slow()
|
||||
test('upload file at exactly 100MB', async ({uploadPage}) => {
|
||||
await uploadPage.selectLargeFile('exactly-100mb.bin', 100 * 1024 * 1024)
|
||||
|
||||
// Should succeed (not show error)
|
||||
await expect(uploadPage.errorStage).toBeHidden({timeout: 5000})
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
// Wait for completion (may take a while)
|
||||
await uploadPage.waitForShareLink(300_000)
|
||||
})
|
||||
```
|
||||
|
||||
### 6.3 Special Characters in Filename
|
||||
|
||||
**Test ID**: `edge-special-chars-filename`
|
||||
|
||||
```typescript
|
||||
test('upload and download file with unicode filename', async ({uploadPage, downloadPage}) => {
|
||||
const fileName = 'test-\u4e2d\u6587-\u0420\u0443\u0441\u0441\u043a\u0438\u0439.txt'
|
||||
|
||||
await uploadPage.selectTextFile(fileName, 'unicode filename test')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
})
|
||||
|
||||
test('upload and download file with spaces', async ({uploadPage, downloadPage}) => {
|
||||
const fileName = 'my document (final) v2.txt'
|
||||
|
||||
await uploadPage.selectTextFile(fileName, 'spaces test')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
})
|
||||
|
||||
test('filename with path separators is sanitized', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectTextFile('../../../etc/passwd', 'path traversal test')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).not.toContain('/')
|
||||
expect(download.suggestedFilename()).not.toContain('\\')
|
||||
})
|
||||
```
|
||||
|
||||
### 6.4 Network Errors (Mocked)
|
||||
|
||||
**Test ID**: `edge-network-error`
|
||||
|
||||
```typescript
|
||||
test('upload handles network error gracefully', async ({uploadPage}) => {
|
||||
// Intercept and abort POST requests
|
||||
await uploadPage.page.route('**/localhost:*', route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.abort('failed')
|
||||
} else {
|
||||
route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await uploadPage.selectTextFile('network-error.txt', 'network error test')
|
||||
await uploadPage.expectError(/.+/) // Any error message
|
||||
})
|
||||
```
|
||||
|
||||
### 6.5 Binary File Content Integrity
|
||||
|
||||
**Test ID**: `edge-binary-content`
|
||||
|
||||
```typescript
|
||||
test('binary file with all byte values', async ({uploadPage, downloadPage}) => {
|
||||
// Create buffer with all 256 byte values
|
||||
const buffer = Buffer.alloc(256)
|
||||
for (let i = 0; i < 256; i++) buffer[i] = i
|
||||
|
||||
await uploadPage.selectFile('all-bytes.bin', buffer)
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const content = (await import('fs')).readFileSync(path)
|
||||
expect(content.length).toBe(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(content[i]).toBe(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.6 Multiple Concurrent Downloads
|
||||
|
||||
**Test ID**: `edge-concurrent-downloads`
|
||||
|
||||
```typescript
|
||||
test('concurrent downloads from same link', async ({browser}) => {
|
||||
const context = await browser.newContext({ignoreHTTPSErrors: true})
|
||||
const page1 = await context.newPage()
|
||||
const upload = new UploadPage(page1)
|
||||
|
||||
await upload.goto()
|
||||
await upload.selectTextFile('concurrent.txt', 'concurrent download test')
|
||||
const link = await upload.waitForShareLink()
|
||||
const hash = upload.getHashFromLink(link)
|
||||
|
||||
// Open two tabs and download concurrently
|
||||
const page2 = await context.newPage()
|
||||
const page3 = await context.newPage()
|
||||
const dl2 = new DownloadPage(page2)
|
||||
const dl3 = new DownloadPage(page3)
|
||||
|
||||
await dl2.goto(hash)
|
||||
await dl3.goto(hash)
|
||||
|
||||
const [download2, download3] = await Promise.all([
|
||||
dl2.clickDownload(),
|
||||
dl3.clickDownload()
|
||||
])
|
||||
|
||||
expect(download2.suggestedFilename()).toBe('concurrent.txt')
|
||||
expect(download3.suggestedFilename()).toBe('concurrent.txt')
|
||||
|
||||
await context.close()
|
||||
})
|
||||
```
|
||||
|
||||
### 6.7 Redirect File Handling (Multi-chunk)
|
||||
|
||||
**Test ID**: `edge-redirect-file`
|
||||
|
||||
```typescript
|
||||
test.slow()
|
||||
test('upload and download multi-chunk file with redirect', async ({uploadPage, downloadPage}) => {
|
||||
// Use ~5MB file to get multiple chunks
|
||||
await uploadPage.selectLargeFile('multi-chunk.bin', 5 * 1024 * 1024)
|
||||
const link = await uploadPage.waitForShareLink(120_000)
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const download = await downloadPage.clickDownload()
|
||||
|
||||
expect(download.suggestedFilename()).toBe('multi-chunk.bin')
|
||||
|
||||
const path = await download.path()
|
||||
if (path) {
|
||||
const stat = (await import('fs')).statSync(path)
|
||||
expect(stat.size).toBe(5 * 1024 * 1024)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.8 UI Information Display
|
||||
|
||||
**Test ID**: `edge-ui-info`
|
||||
|
||||
```typescript
|
||||
test('upload complete shows expiry and security note', async ({uploadPage}) => {
|
||||
await uploadPage.selectTextFile('ui-test.txt', 'ui test')
|
||||
await uploadPage.waitForShareLink()
|
||||
|
||||
await uploadPage.expectCompleteWithExpiry()
|
||||
await uploadPage.expectSecurityNote()
|
||||
})
|
||||
|
||||
test('download page shows file size and security note', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('size-test.bin', createTestContent(1024))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
await downloadPage.expectFileSizeDisplayed()
|
||||
await downloadPage.expectSecurityNote()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order
|
||||
|
||||
### Phase 1: Core Infrastructure (Priority: High)
|
||||
1. Create `test/pages/UploadPage.ts` with Page Object
|
||||
2. Create `test/pages/DownloadPage.ts` with Page Object
|
||||
3. Create `test/fixtures.ts` with extended test function
|
||||
4. Refactor existing test to use Page Objects
|
||||
|
||||
### Phase 2: Core Happy Path (Priority: High)
|
||||
5. `upload-file-picker` - Basic upload via file picker
|
||||
6. `download-button-click` - Basic download
|
||||
7. `download-file-save` - Content verification
|
||||
|
||||
### Phase 3: Validation (Priority: High)
|
||||
8. `upload-file-too-large` - Size validation
|
||||
9. `upload-file-empty` - Empty file validation
|
||||
10. `download-invalid-hash-malformed` - Invalid link handling
|
||||
11. `download-invalid-hash-structure` - Invalid structure handling
|
||||
|
||||
### Phase 4: Progress and Cancel (Priority: Medium)
|
||||
12. `upload-progress-display` - Progress visibility
|
||||
13. `upload-cancel` - Cancel functionality
|
||||
14. `download-progress-display` - Download progress
|
||||
|
||||
### Phase 5: Link Sharing (Priority: Medium)
|
||||
15. `upload-share-link-copy` - Copy button functionality
|
||||
16. `upload-drag-drop` - Drag-drop upload
|
||||
|
||||
### Phase 6: Edge Cases (Priority: Low)
|
||||
17. `edge-small-file` - 1-byte file
|
||||
18. `edge-special-chars-filename` - Unicode/special characters
|
||||
19. `edge-binary-content` - Binary content integrity
|
||||
20. `edge-near-limit` - 100MB file (slow test)
|
||||
21. `edge-network-error` - Network error handling
|
||||
|
||||
### Phase 7: Error Recovery and Advanced (Priority: Low)
|
||||
22. `upload-error-retry` - Retry after error
|
||||
23. `edge-concurrent-downloads` - Concurrent access
|
||||
24. `edge-redirect-file` - Multi-chunk file with redirect (slow)
|
||||
25. `edge-ui-info` - Expiry message, security notes
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Utilities
|
||||
|
||||
### 8.1 Shared Test Setup
|
||||
|
||||
```typescript
|
||||
// test/page.spec.ts
|
||||
import {test, expect, createTestContent, createTextContent, uniqueFileName} from './fixtures'
|
||||
|
||||
test.describe('Upload Flow', () => {
|
||||
test('upload via file picker', async ({uploadPage}) => {
|
||||
// Tests use uploadPage fixture which navigates automatically
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Download Flow', () => {
|
||||
test('download works', async ({uploadPage, downloadPage}) => {
|
||||
// Both pages available via fixtures
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Cases', () => {
|
||||
// Edge case tests
|
||||
})
|
||||
```
|
||||
|
||||
### 8.2 File Structure
|
||||
|
||||
```
|
||||
xftp-web/test/
|
||||
├── fixtures.ts # Playwright fixtures with page objects
|
||||
├── pages/
|
||||
│ ├── UploadPage.ts # Upload page object
|
||||
│ └── DownloadPage.ts # Download page object
|
||||
├── page.spec.ts # All E2E tests
|
||||
└── globalSetup.ts # Server startup (existing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Test Matrix
|
||||
|
||||
| Test ID | Category | Priority | Estimated Time | Dependencies |
|
||||
|---------|----------|----------|----------------|--------------|
|
||||
| upload-file-picker | Upload | High | 30s | - |
|
||||
| upload-drag-drop | Upload | Medium | 30s | - |
|
||||
| upload-file-too-large | Upload | High | 5s | - |
|
||||
| upload-file-empty | Upload | High | 5s | - |
|
||||
| upload-progress-display | Upload | Medium | 45s | - |
|
||||
| upload-cancel | Upload | Medium | 30s | - |
|
||||
| upload-share-link-copy | Upload | Medium | 30s | - |
|
||||
| upload-error-retry | Upload | Low | 30s | - |
|
||||
| download-invalid-hash-malformed | Download | High | 5s | - |
|
||||
| download-invalid-hash-structure | Download | High | 5s | - |
|
||||
| download-button-click | Download | High | 45s | upload |
|
||||
| download-progress-display | Download | Medium | 60s | upload |
|
||||
| download-file-save | Download | High | 45s | upload |
|
||||
| edge-small-file | Edge | Low | 30s | - |
|
||||
| edge-near-limit | Edge | Low | 300s | - |
|
||||
| edge-special-chars-filename | Edge | Low | 30s | - |
|
||||
| edge-network-error | Edge | Low | 45s | - |
|
||||
| edge-binary-content | Edge | Low | 30s | - |
|
||||
| edge-concurrent-downloads | Edge | Low | 60s | upload |
|
||||
| edge-redirect-file | Edge | Low | 120s | - |
|
||||
| edge-ui-info | Edge | Low | 60s | upload |
|
||||
|
||||
**Total estimated time**: ~18 minutes (excluding 100MB and 5MB tests)
|
||||
@@ -0,0 +1,221 @@
|
||||
# XFTP Web Hello Header — Session Re-handshake for Browser Connection Reuse
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Browser HTTP/2 connection pooling reuses TLS connections across page navigations (same origin = same connection pool). The XFTP server maintains per-TLS-connection session state in `TMap SessionId Handshake` keyed by `tlsUniq tls`. When a browser navigates from the upload page to the download page (or reloads), the new page sends a fresh ClientHello on the reused HTTP/2 connection. The server is already in `HandshakeAccepted` state for that connection, so it routes the request to `processRequest`, which expects a 16384-byte command block but receives a 34-byte ClientHello → `ERR BLOCK`.
|
||||
|
||||
**Root cause**: The server cannot distinguish a ClientHello from a command on an already-handshaked connection because both arrive on the same HTTP/2 connection (same `tlsUniq`), and there is no content-level discriminator (ClientHello is unpadded, but the server never gets to parse it — the size check in `processRequest` rejects it first).
|
||||
|
||||
**Browser limitation**: `fetch()` provides zero control over HTTP/2 connection pooling. There is no browser API to force a new connection or detect connection reuse before a request is sent.
|
||||
|
||||
## 2. Solution Summary
|
||||
|
||||
Add an HTTP header `xftp-web-hello` to web ClientHello requests. When the server sees this header on an already-handshaked connection (`HandshakeAccepted` state), it re-runs `processHello` **reusing the existing session keys** (same X25519 key pair from the original handshake). The client then completes the normal handshake flow (sends ClientHandshake, receives ack) and proceeds with commands.
|
||||
|
||||
Key properties:
|
||||
- Server reuses existing `serverPrivKey` — no new key material generated on re-handshake, so `thAuth` remains consistent with any in-flight commands on concurrent HTTP/2 streams.
|
||||
- Header is only checked when `sniUsed` is true (web/browser connections). Native XFTP clients are unaffected.
|
||||
- CORS preflight already allows all headers (`Access-Control-Allow-Headers: *`).
|
||||
- Web clients always send this header on ClientHello — it's harmless on first connection (`Nothing` state) and enables re-handshake on reused connections (`HandshakeAccepted` state).
|
||||
|
||||
## 3. Detailed Technical Design
|
||||
|
||||
### 3.1 Server change: parameterize `processHello` (`src/Simplex/FileTransfer/Server.hs`)
|
||||
|
||||
The entire server change is parameterizing the existing `processHello` with `Maybe C.PrivateKeyX25519`. Zero new functions.
|
||||
|
||||
#### Current code (lines 165-191):
|
||||
|
||||
```haskell
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions
|
||||
XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams) -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
processHello = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
| sniUsed -> do
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- atomically . C.generateKeyPair =<< asks random
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
-- ...build and send ServerHandshake...
|
||||
pure Nothing
|
||||
```
|
||||
|
||||
#### After (diff is ~10 lines):
|
||||
|
||||
```haskell
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions
|
||||
XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, request, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
-- ^^^^^^^ bind request
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello Nothing
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams)
|
||||
| webHello -> processHello (serverPrivKey <$> thAuth thParams)
|
||||
| otherwise -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
webHello = sniUsed && any (\(t, _) -> tokenKey t == "xftp-web-hello") (fst $ H.requestHeaders request)
|
||||
processHello pk_ = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
| sniUsed -> do
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- maybe
|
||||
(atomically . C.generateKeyPair =<< asks random)
|
||||
(\pk -> pure (C.publicKey pk, pk))
|
||||
pk_
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
-- ...rest unchanged...
|
||||
pure Nothing
|
||||
```
|
||||
|
||||
#### What changes:
|
||||
|
||||
1. **Bind `request`** in the `XFTPTransportRequest` pattern (+1 field)
|
||||
2. **Add `webHello`** binding in `where` clause (1 line) — checks header only when `sniUsed`
|
||||
3. **Add `pk_` parameter** to `processHello` (change signature)
|
||||
4. **Replace key generation** with `maybe` that generates fresh keys when `pk_ = Nothing`, or derives public from existing private when `pk_ = Just pk` (3 lines replace 1 line)
|
||||
5. **Add guard** in `HandshakeAccepted` branch (2 lines replace 1 line)
|
||||
6. **Call site** `Nothing -> processHello Nothing` (+1 word)
|
||||
7. **One import** added: `Network.HPACK.Token (tokenKey)`
|
||||
|
||||
#### Imports to add:
|
||||
|
||||
```haskell
|
||||
import Network.HPACK.Token (tokenKey)
|
||||
```
|
||||
|
||||
`OverloadedStrings` (already enabled in Server.hs) provides the `IsString` instance for `CI ByteString`, so `tokenKey t == "xftp-web-hello"` works without importing `Data.CaseInsensitive`. Verified on Hackage: `requestHeaders :: Request -> HeaderTable`, `tokenKey :: Token -> CI ByteString`.
|
||||
|
||||
### 3.2 Re-handshake flow
|
||||
|
||||
When `webHello` is true in `HandshakeAccepted` state:
|
||||
|
||||
1. `processHello (serverPrivKey <$> thAuth thParams)` is called with `Just pk` (existing private key)
|
||||
2. `(k, pk) <- pure (C.publicKey pk, pk)` — reuses same key pair, no generation
|
||||
3. `TM.insert sessionId (HandshakeSent pk) sessions` — transitions state back to `HandshakeSent` with same `pk`
|
||||
4. Server sends `ServerHandshake` response (same format as initial handshake)
|
||||
5. Client sends `ClientHandshake` on next stream → enters `Just (HandshakeSent pk) -> processClientHandshake pk` → normal flow
|
||||
6. `processClientHandshake` stores `HandshakeAccepted thParams` with same `serverPrivKey = pk`
|
||||
|
||||
### 3.3 Web client change (`xftp-web/src/client.ts`)
|
||||
|
||||
Add optional `headers?` parameter to `Transport.post()`, thread it through `fetch()` and `session.request()`, and pass `{"xftp-web-hello": "1"}` in the ClientHello call in `connectXFTP`.
|
||||
|
||||
### 3.4 What does NOT change
|
||||
|
||||
- **CORS**: Already has `Access-Control-Allow-Headers: *` (Server.hs:106).
|
||||
- **Native Haskell client**: Uses `[]` headers. No header = existing behavior.
|
||||
- **Protocol wire format**: ClientHello, ServerHandshake, ClientHandshake, commands — all unchanged.
|
||||
- **`processRequest`**, **`processClientHandshake`**, **`sendError`**, **`encodeXftp`** — unchanged.
|
||||
|
||||
### 3.5 Haskell test (`tests/XFTPServerTests.hs`)
|
||||
|
||||
Add `testWebReHandshake` next to the existing `testWebHandshake` (line 504). It reuses the same SNI + HTTP/2 setup pattern, performs a full handshake, then sends a second ClientHello with the `xftp-web-hello` header on the same connection and verifies the server responds with a valid ServerHandshake (same `sessionId`), then completes the second handshake.
|
||||
|
||||
```haskell
|
||||
-- Register in xftpServerTests (after line 86):
|
||||
it "should re-handshake on same connection with xftp-web-hello header" testWebReHandshake
|
||||
|
||||
-- Test (after testWebHandshake):
|
||||
testWebReHandshake :: Expectation
|
||||
testWebReHandshake =
|
||||
withXFTPServerSNI $ \_ -> do
|
||||
Fingerprint fp <- loadFileFingerprint "tests/fixtures/ca.crt"
|
||||
let keyHash = C.KeyHash fp
|
||||
cfg = defaultTransportClientConfig {clientALPN = Just ["h2"], useSNI = True}
|
||||
runTLSTransportClient defaultSupportedParamsHTTPS Nothing cfg Nothing "localhost" xftpTestPort (Just keyHash) $ \(tls :: TLS 'TClient) -> do
|
||||
let h2cfg = HC.defaultHTTP2ClientConfig {HC.bodyHeadSize = 65536}
|
||||
h2 <- either (error . show) pure =<< HC.attachHTTP2Client h2cfg (THDomainName "localhost") xftpTestPort mempty 65536 tls
|
||||
g <- C.newRandom
|
||||
-- First handshake (same as testWebHandshake)
|
||||
challenge1 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq1 = H2.requestBuilder "POST" "/" [] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge1}))
|
||||
resp1 <- either (error . show) pure =<< HC.sendRequest h2 helloReq1 (Just 5000000)
|
||||
shs1 <- either error pure $ smpDecode =<< C.unPad (bodyHead (HC.respBody resp1))
|
||||
let XFTPServerHandshake {sessionId = sid1} = shs1
|
||||
clientHsPadded <- either (error . show) pure $ C.pad (smpEncode (XFTPClientHandshake {xftpVersion = VersionXFTP 1, keyHash})) xftpBlockSize
|
||||
resp1b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp1b)) `shouldBe` 0
|
||||
-- Second handshake on same connection with xftp-web-hello header
|
||||
challenge2 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq2 = H2.requestBuilder "POST" "/" [("xftp-web-hello", "1")] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge2}))
|
||||
resp2 <- either (error . show) pure =<< HC.sendRequest h2 helloReq2 (Just 5000000)
|
||||
shs2 <- either error pure $ smpDecode =<< C.unPad (bodyHead (HC.respBody resp2))
|
||||
let XFTPServerHandshake {sessionId = sid2} = shs2
|
||||
sid2 `shouldBe` sid1 -- same TLS connection → same sessionId
|
||||
-- Complete second handshake
|
||||
resp2b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp2b)) `shouldBe` 0
|
||||
```
|
||||
|
||||
The only difference from `testWebHandshake`: the second `helloReq2` passes `[("xftp-web-hello", "1")]` instead of `[]`. The test verifies:
|
||||
1. Server responds with `ServerHandshake` (not `ERR BLOCK`)
|
||||
2. Same `sessionId` (same TLS connection)
|
||||
3. Second `ClientHandshake` completes with empty ACK
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Step 1: Server — parameterize `processHello`
|
||||
|
||||
Apply the diff from Section 3.1 to `src/Simplex/FileTransfer/Server.hs`.
|
||||
|
||||
### Step 2: Test — add `testWebReHandshake`
|
||||
|
||||
Add the test from Section 3.5 to `tests/XFTPServerTests.hs`.
|
||||
|
||||
### Step 3: Client — add `xftp-web-hello` header
|
||||
|
||||
Add optional `headers?` to `Transport.post()`, pass `{"xftp-web-hello": "1"}` on ClientHello in `connectXFTP`.
|
||||
|
||||
### Step 4: Test
|
||||
|
||||
Run Haskell tests (`cabal test`) and E2E Playwright tests (`npx playwright test` in `xftp-web/`).
|
||||
|
||||
## 5. Race Condition Analysis
|
||||
|
||||
### Single-tab navigation (the common case)
|
||||
|
||||
1. Upload page completes, all fetch() requests finish
|
||||
2. Browser navigates to download page (or reloads)
|
||||
3. All upload-page fetches are aborted on page unload
|
||||
4. Download page sends ClientHello with `xftp-web-hello` header
|
||||
5. Server is in `HandshakeAccepted` → `processHello (Just pk)` → `HandshakeSent pk` (same key)
|
||||
6. No concurrent streams → no race
|
||||
|
||||
**Safe.**
|
||||
|
||||
### Multi-tab (edge case)
|
||||
|
||||
Tab A (upload) and Tab B (download) share the same HTTP/2 connection.
|
||||
|
||||
1. Tab A has active command streams (e.g., FPUT upload in progress)
|
||||
2. Tab B sends ClientHello with header
|
||||
3. Server reads `HandshakeAccepted` atomically for both streams
|
||||
4. Tab A's stream already has its `thParams` snapshot → proceeds with `processRequest` using old `thParams`
|
||||
5. Tab B's stream triggers `processHello (Just pk)` → stores `HandshakeSent pk` (same pk!)
|
||||
6. Tab A's in-progress FPUT continues with snapshot `thParams` → completes normally (same `serverPrivKey`)
|
||||
7. Tab A's NEXT command reads `HandshakeSent` from TMap → enters `processClientHandshake` → fails (command body ≠ ClientHandshake format) → HANDSHAKE error
|
||||
|
||||
**Tab A's in-flight commands succeed. Tab A's subsequent commands fail with HANDSHAKE error.** This is the inherent multi-tab problem — unavoidable with per-connection session state and HTTP/2 connection sharing. The failure is clean (HANDSHAKE error, not silent corruption).
|
||||
|
||||
## 6. Security Considerations
|
||||
|
||||
- **No new key material**: Re-handshake reuses existing `serverPrivKey`. No opportunity for key confusion or downgrade.
|
||||
- **Identity re-verification**: Server re-signs the web challenge with its long-term signing key. Client verifies identity again.
|
||||
- **Header cannot escalate privileges**: The header only triggers re-handshake (which the server was already capable of doing on first connection). It does not bypass any authentication.
|
||||
- **Timing**: Re-handshake takes the same code path as initial handshake, so timing side-channels are unchanged.
|
||||
@@ -0,0 +1,948 @@
|
||||
# XFTP Web Error Handling and Connection Resilience
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The XFTP web client is fundamentally fragile: any transient error (browser opening a new HTTP/2 connection, network hiccup, server restart) causes an unrecoverable failure with a cryptic error message. There is no retry logic, no fetch timeout, no error categorization, and the upload uses a single server instead of distributing chunks across preset servers. This makes the app frustrating — it works most of the time but fails unpredictably, which is worse than being completely broken.
|
||||
|
||||
### Confirmed root cause (from diagnostic logs)
|
||||
|
||||
When the browser opens a new HTTP/2 connection mid-operation, the new connection has a different TLS SessionId with no handshake state in the server's `TMap SessionId Handshake`. The server's `Nothing` branch in `xftpServerHandshakeV1` (Server.hs:169) unconditionally calls `processHello`, which tries to decode the command body as `XFTPClientHello`, fails, and sends a raw padded "HANDSHAKE" error string. The client cannot parse this as a proper transmission (first byte 'H' = 72 is read as batch count), producing `"expected batch count 1, got 72"`.
|
||||
|
||||
Server log confirming the SessionId change:
|
||||
```
|
||||
DEBUG dispatch: Accepted+command sessId="ZSo1GGETgIvjbB7CWHbvGPpbMjx_b2IlC1eTI6aKfqc="
|
||||
...20 successful commands...
|
||||
DEBUG dispatch: Nothing sessId="mJC7Sck9xxW5UsXoPGoUWduuHghSVgf6CnD6ZC6SBhU=" webHello=False
|
||||
```
|
||||
|
||||
### Why re-handshake is required (cannot be made optional)
|
||||
|
||||
1. **SessionId is baked into signed command data.** `encodeAuthTransmission` signs `concat(encode(sessionId), tInner)` with Ed25519. Server's `tDecodeServer` (Protocol.hs:2242) verifies `sessId == sessionId`. New connection = different sessionId = signature mismatch.
|
||||
2. **Server generates per-session DH keys.** `processHello` creates fresh X25519 keypair stored in `HandshakeSent`. For SMP browser clients (future), `verifyCmdAuth` (Protocol.hs:1322) requires the matching `serverPrivKey` from `thAuth`.
|
||||
3. **This applies to both XFTP and future SMP browser clients** — the session management approach is the same.
|
||||
|
||||
### Why multiple preset servers cannot work
|
||||
|
||||
Upload (`agent.ts:105-157`) takes a single `server: XFTPServer` parameter and uploads ALL chunks to it. `web/upload.ts:133` calls `pickRandomServer(servers)` which selects ONE random server from all presets. The multi-server preset configuration is pointless — only one server is ever used per upload. The design intent (RFC section 11.6: "upload in parallel to 8 randomly selected servers") is not implemented. This must be fixed in Phase 2 (section 3.7).
|
||||
|
||||
## 2. Solution Summary
|
||||
|
||||
### Phase 1: Error handling and connection resilience
|
||||
|
||||
1. **Server: strict dispatch for allowed protocol combinations** — reject all invalid combinations
|
||||
2. **Client: automatic retry with re-handshake** on SESSION/HANDSHAKE errors
|
||||
3. **Client: fetch timeout** with configurable duration
|
||||
4. **UI: error categorization and retry** — auto-retry temporary, human-readable permanent
|
||||
5. **Client: connection state with Promise-based lock and per-server queues** — `ServerConnection` with `client: Promise<XFTPClient>` + `queue: Promise<void>`
|
||||
6. **Client: fix cache key** — include keyHash
|
||||
|
||||
### Phase 2: Multi-server upload (after Phase 1)
|
||||
|
||||
7. **Multi-server upload with server selection and failover** — distribute chunks across servers, retry FNEW on different server if one fails
|
||||
|
||||
## 3. Detailed Technical Design
|
||||
|
||||
### 3.1 Server: strict dispatch for allowed protocol combinations
|
||||
|
||||
**Principle:** Everything not explicitly done by existing Haskell/TS clients is prohibited. It is better to fail on impossible combinations than to be permissive — permissiveness complicates debugging and creates attack vectors via unexpected behaviors.
|
||||
|
||||
**Allowed behaviors by client type:**
|
||||
|
||||
| Client | SNI | webHello header | Hello body | When |
|
||||
|--------|-----|----------------|------------|------|
|
||||
| Haskell | No | No | Empty | New connection only |
|
||||
| Web | Yes | Yes | Non-empty (XFTPClientHello) | New OR existing connection |
|
||||
|
||||
**Minimal surgical change.** The existing dispatch (Server.hs:169-189) already correctly handles `HandshakeSent` and `HandshakeAccepted` — their guards cover all valid and invalid combinations. The ONLY missing case is `Nothing` + web client sending a command on a stale session.
|
||||
|
||||
`processHello` (Server.hs:194-217) already internally routes: `B.null bodyHead` → Haskell hello, `sniUsed` → web hello decode, else → HANDSHAKE. For stale web sessions, it currently tries to decode a command body as `XFTPClientHello`, fails, and throws HANDSHAKE. The fix: detect this case BEFORE calling processHello and throw SESSION instead, so the client knows to re-handshake (not that its hello was malformed).
|
||||
|
||||
**Change: add one guard to `Nothing` branch, remove debug logging.**
|
||||
|
||||
```haskell
|
||||
-- Before (1 line):
|
||||
Nothing -> processHello Nothing
|
||||
|
||||
-- After (3 lines):
|
||||
Nothing
|
||||
| sniUsed && not webHello -> throwE SESSION -- web command on stale session
|
||||
| otherwise -> processHello Nothing -- normal hello (web or Haskell)
|
||||
```
|
||||
|
||||
`throwE SESSION` is caught by `either sendError pure r` (line 190). `sendError` pads `smpEncode SESSION` = `"SESSION"` (Transport.hs:298) to `xftpBlockSize`. The client's padded error detection (section 3.2) catches this as a retriable error and triggers re-handshake. SESSION is a valid `XFTPErrorType` constructor (Transport.hs:225) — no new helpers needed.
|
||||
|
||||
**All other branches remain unchanged.** `HandshakeSent` guards (`webHello` → processHello, `otherwise` → processClientHandshake with body size check inside) are correct. `HandshakeAccepted` guards (`webHello`, `webHandshake`, `otherwise` → command) are correct.
|
||||
|
||||
### 3.2 Client: automatic retry with re-handshake
|
||||
|
||||
**Location:** `sendXFTPCommand` in `client.ts`
|
||||
|
||||
**Design:** Retry loop inside `sendXFTPCommand`. Maximum 3 attempts. On retriable error, close old client, re-handshake, retry.
|
||||
|
||||
**Error classification:**
|
||||
|
||||
| Error | Type | Retriable? | Human-readable message |
|
||||
|-------|------|-----------|----------------------|
|
||||
| Padded "HANDSHAKE" | Temporary | Yes (auto) | "Connection interrupted, reconnecting..." |
|
||||
| Padded "SESSION" | Temporary | Yes (auto) | "Session expired, reconnecting..." |
|
||||
| `FRErr SESSION` | Temporary | Yes (auto) | "Session expired, reconnecting..." |
|
||||
| `FRErr HANDSHAKE` | Temporary | Yes (auto) | "Connection interrupted, reconnecting..." |
|
||||
| `fetch()` TypeError | Temporary | Yes (auto) | "Network error, retrying..." |
|
||||
| AbortError (timeout) | Temporary | Yes (auto) | "Server timeout, retrying..." |
|
||||
| `FRErr AUTH` | Permanent | No | "File is invalid, expired, or has been removed" |
|
||||
| `FRErr NO_FILE` | Permanent | No | "File not found — it may have expired" |
|
||||
| `FRErr SIZE` | Permanent | No | "File size exceeds server limit" |
|
||||
| `FRErr QUOTA` | Permanent | No | "Server storage quota exceeded" |
|
||||
| `FRErr BLOCKED` | Permanent | No | "File has been blocked by server" |
|
||||
| `FRErr DIGEST` | Permanent | No | "File integrity check failed" |
|
||||
| `FRErr INTERNAL` | Permanent | No | "Server internal error" |
|
||||
| `CMD *` | Permanent | No | "Protocol error" |
|
||||
|
||||
**Retry behavior:**
|
||||
- Auto-retry up to 3 times for temporary errors, transparent to user
|
||||
- After 3 failures: show human-readable error with diagnosis, offer manual retry button
|
||||
- Permanent errors: show human-readable error immediately, NO manual retry button (user can reload page)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
async function sendXFTPCommand(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
privateKey: Uint8Array,
|
||||
entityId: Uint8Array,
|
||||
cmdBytes: Uint8Array,
|
||||
chunkData?: Uint8Array,
|
||||
maxRetries: number = 3
|
||||
): Promise<{response: FileResponse, body: Uint8Array}> {
|
||||
let clientP = getXFTPServerClient(agent, server)
|
||||
let client = await clientP
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await sendXFTPCommandOnce(client, privateKey, entityId, cmdBytes, chunkData)
|
||||
} catch (e) {
|
||||
if (!isRetriable(e)) {
|
||||
// Permanent error (AUTH, NO_FILE, etc.) — connection is fine, don't touch it
|
||||
throw categorizeError(e)
|
||||
}
|
||||
if (attempt === maxRetries) {
|
||||
// Retriable error exhausted — connection is bad, remove stale promise
|
||||
removeStaleConnection(agent, server, clientP)
|
||||
throw categorizeError(e)
|
||||
}
|
||||
clientP = reconnectClient(agent, server)
|
||||
client = await clientP
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
```
|
||||
|
||||
**`sendXFTPCommandOnce`** — renamed from current `sendXFTPCommand`. Two changes:
|
||||
|
||||
1. **Padded error detection** (before `decodeTransmission`):
|
||||
|
||||
```typescript
|
||||
// After getting respBlock, before decodeTransmission:
|
||||
const raw = blockUnpad(respBlock)
|
||||
if (raw.length < 20) {
|
||||
const text = new TextDecoder().decode(raw)
|
||||
if (/^[A-Z_]+$/.test(text)) {
|
||||
throw new XFTPRetriableError(text) // "HANDSHAKE" or "SESSION"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **FRErr classification** (replaces current unconditional throw):
|
||||
|
||||
```typescript
|
||||
// After decodeResponse, instead of throw new Error("Server error: " + err.type):
|
||||
if (response.type === "FRErr") {
|
||||
const err = response.err
|
||||
if (err.type === "SESSION" || err.type === "HANDSHAKE") {
|
||||
throw new XFTPRetriableError(err.type)
|
||||
}
|
||||
throw new XFTPPermanentError(err.type, humanReadableMessage(err))
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Client: fetch timeout
|
||||
|
||||
**Location:** `createBrowserTransport` and `createNodeTransport` in `client.ts`
|
||||
|
||||
**Design:** `AbortController` with configurable timeout on every `fetch()`.
|
||||
|
||||
```typescript
|
||||
interface TransportConfig {
|
||||
timeoutMs: number // default 30000, lower for tests
|
||||
}
|
||||
|
||||
function createBrowserTransport(baseUrl: string, config: TransportConfig): Transport {
|
||||
return {
|
||||
async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
||||
try {
|
||||
const resp = await fetch(effectiveUrl, {
|
||||
method: "POST", headers, body,
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!resp.ok) throw new Error(`Server request failed: ${resp.status}`)
|
||||
return new Uint8Array(await resp.arrayBuffer())
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
close() {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Node.js transport, use `setTimeout` on the HTTP/2 request stream.
|
||||
|
||||
Default: 30s for production, 5s for tests. Threaded through `connectXFTP` → `createTransport`.
|
||||
|
||||
### 3.4 UI: error categorization and retry
|
||||
|
||||
**Behavior (Option D):**
|
||||
|
||||
- **Temporary errors:** Auto-retry loop (3 attempts). After 3 failures, show human-readable diagnosis with manual retry button. Diagnosis examples: "Server timeout — the server may be temporarily unavailable", "Connection interrupted — your network may be unstable".
|
||||
- **Permanent errors:** Show human-readable error immediately, NO retry button. User can reload page if they want to retry. Examples: "File is invalid, expired, or has been removed" (AUTH), "File not found" (NO_FILE).
|
||||
|
||||
**Current UI retry buttons:**
|
||||
- `upload.ts:73-75` — retry calls `startUpload(pendingFile)` from scratch
|
||||
- `download.ts:60` — retry calls `startDownload()` from scratch
|
||||
|
||||
**Improvement:** Track uploaded/downloaded chunk indices. On manual retry, skip completed chunks:
|
||||
|
||||
```typescript
|
||||
// Upload: track which chunks completed
|
||||
const completedChunks: Set<number> = new Set()
|
||||
for (let i = 0; i < specs.length; i++) {
|
||||
if (completedChunks.has(i)) continue
|
||||
// ... create + upload chunk
|
||||
completedChunks.add(i)
|
||||
}
|
||||
|
||||
// Download: already naturally resumable — each chunk is independent
|
||||
```
|
||||
|
||||
### 3.5 Client: connection state with Promise-based lock and per-server queues
|
||||
|
||||
**Design:** Each server gets a `ServerConnection` record containing a `Promise<XFTPClient>` (the connection lock) and a `Promise<void>` (the sequential command queue). The `XFTPClientAgent` maps server keys to these records.
|
||||
|
||||
The promise IS the lock — every consumer awaits the same promise. When reconnect is needed, the promise is replaced atomically.
|
||||
|
||||
```typescript
|
||||
interface ServerConnection {
|
||||
client: Promise<XFTPClient> // resolves to connected client; replaced on reconnect
|
||||
queue: Promise<void> // tail of sequential command chain
|
||||
}
|
||||
|
||||
interface XFTPClientAgent {
|
||||
connections: Map<string, ServerConnection>
|
||||
}
|
||||
|
||||
function newXFTPAgent(): XFTPClientAgent {
|
||||
return {connections: new Map()}
|
||||
}
|
||||
```
|
||||
|
||||
**Connection lifecycle — `getXFTPServerClient` and `reconnectClient`:**
|
||||
|
||||
```typescript
|
||||
function getXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
|
||||
const key = formatXFTPServer(server)
|
||||
let conn = agent.connections.get(key)
|
||||
if (!conn) {
|
||||
const p = connectXFTP(server)
|
||||
conn = {client: p, queue: Promise.resolve()}
|
||||
agent.connections.set(key, conn)
|
||||
// On connection failure, remove from map so next call retries
|
||||
p.catch(() => {
|
||||
const cur = agent.connections.get(key)
|
||||
if (cur && cur.client === p) agent.connections.delete(key)
|
||||
})
|
||||
}
|
||||
return conn.client
|
||||
}
|
||||
|
||||
function reconnectClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
|
||||
const key = formatXFTPServer(server)
|
||||
const old = agent.connections.get(key)
|
||||
// Close old client (fire-and-forget)
|
||||
old?.client.then(c => c.transport.close(), () => {})
|
||||
// Replace with new connection promise — all concurrent callers will await this
|
||||
// Queue survives reconnect — pending operations stay ordered
|
||||
const p = connectXFTP(server)
|
||||
const conn: ServerConnection = {client: p, queue: old?.queue ?? Promise.resolve()}
|
||||
agent.connections.set(key, conn)
|
||||
p.catch(() => {
|
||||
const cur = agent.connections.get(key)
|
||||
if (cur && cur.client === p) agent.connections.delete(key)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
function closeXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): void {
|
||||
const key = formatXFTPServer(server)
|
||||
const conn = agent.connections.get(key)
|
||||
if (conn) {
|
||||
agent.connections.delete(key)
|
||||
conn.client.then(c => c.transport.close(), () => {})
|
||||
}
|
||||
}
|
||||
|
||||
function closeXFTPAgent(agent: XFTPClientAgent): void {
|
||||
for (const conn of agent.connections.values()) {
|
||||
conn.client.then(c => c.transport.close(), () => {})
|
||||
}
|
||||
agent.connections.clear()
|
||||
}
|
||||
```
|
||||
|
||||
**Precise semantics:**
|
||||
|
||||
1. `getXFTPServerClient(agent, server)` — returns existing `conn.client` promise if present, otherwise creates a new `ServerConnection` with fresh connection and empty queue
|
||||
2. When error detected, first caller calls `reconnectClient` which replaces `conn.client` with a new connection promise. The queue is preserved across reconnect.
|
||||
3. All concurrent callers awaiting the OLD promise receive the error
|
||||
4. They then call `getXFTPServerClient` which returns the NEW promise
|
||||
5. If reconnection fails, auto-cleanup (`p.catch(() => delete)`) removes the entry so the next caller starts fresh
|
||||
|
||||
**Stale error cleanup rule:** When a caller exhausts retries for a retriable error, it removes the failed entry from the map (only if no concurrent caller has already replaced it via `reconnectClient`). This prevents the next caller from receiving a stale rejected promise. Permanent errors (AUTH, NO_FILE, etc.) do NOT remove the connection — the transport is fine, only the command failed.
|
||||
|
||||
```typescript
|
||||
function removeStaleConnection(
|
||||
agent: XFTPClientAgent, server: XFTPServer, failedP: Promise<XFTPClient>
|
||||
): void {
|
||||
const key = formatXFTPServer(server)
|
||||
const conn = agent.connections.get(key)
|
||||
// Only remove if current promise is the one that failed — not if already replaced by reconnect
|
||||
if (conn && conn.client === failedP) {
|
||||
agent.connections.delete(key)
|
||||
failedP.then(c => c.transport.close(), () => {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Per-server sequential queue:** `queue` is a `Promise<void>` — the tail of the sequential operation chain. Each new operation `.then()`s onto it. It's `void` because callers hold their own typed promises; the queue only tracks completion order:
|
||||
|
||||
```typescript
|
||||
async function enqueueCommand<T>(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
fn: () => Promise<T> // no client param — fn uses command wrappers (agent+server)
|
||||
): Promise<T> {
|
||||
const key = formatXFTPServer(server)
|
||||
// Ensure connection exists (with auto-cleanup on failure)
|
||||
await getXFTPServerClient(agent, server)
|
||||
const conn = agent.connections.get(key)! // guaranteed to exist after getXFTPServerClient
|
||||
// Chain onto the queue — fn runs after previous operation completes
|
||||
let resolve_: (v: T) => void, reject_: (e: any) => void
|
||||
const result = new Promise<T>((res, rej) => { resolve_ = res; reject_ = rej })
|
||||
conn.queue = conn.queue.then(
|
||||
() => fn().then(resolve_!, reject_!),
|
||||
() => fn().then(resolve_!, reject_!)
|
||||
).then(() => {}, () => {}) // swallow errors in the chain
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Commands to the same server execute one at a time via the queue. Commands to different servers execute concurrently because each has its own queue. `enqueueCommand` provides sequencing; `sendXFTPCommand` (called inside `fn` via command wrappers) provides retry. They compose as: `enqueueCommand` sequences calls to wrappers that internally use `sendXFTPCommand`.
|
||||
|
||||
**Download change:** Group chunks by server, process each server's chunks sequentially, servers in parallel. Uses `for` loop for per-server sequencing (same pattern as Stage 2 upload). `enqueueCommand` is available for cases where different callers target the same server.
|
||||
|
||||
```typescript
|
||||
const byServer = new Map<string, FileChunk[]>()
|
||||
for (const chunk of resolvedFd.chunks) {
|
||||
const srv = chunk.replicas[0]?.server ?? ""
|
||||
if (!byServer.has(srv)) byServer.set(srv, [])
|
||||
byServer.get(srv)!.push(chunk)
|
||||
}
|
||||
await Promise.all([...byServer.entries()].map(async ([srv, chunks]) => {
|
||||
const server = parseXFTPServer(srv)
|
||||
for (const chunk of chunks) {
|
||||
const seed = decodePrivKeyEd25519(chunk.replicas[0].replicaKey)
|
||||
const kp = ed25519KeyPairFromSeed(seed)
|
||||
const raw = await downloadXFTPChunkRaw(agent, server, kp.privateKey, chunk.replicas[0].replicaId)
|
||||
await onRawChunk({chunkNo: chunk.chunkNo, dhSecret: raw.dhSecret, nonce: raw.nonce, body: raw.body, digest: chunk.digest})
|
||||
downloaded += chunk.chunkSize
|
||||
onProgress?.(downloaded, resolvedFd.size)
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### 3.6 Fix cache key
|
||||
|
||||
**Bug:** `getXFTPServerClient` (client.ts:110) uses `"https://" + server.host + ":" + server.port` as cache key, ignoring `keyHash`. Two servers with same host:port but different keyHash share a cached connection, bypassing identity verification.
|
||||
|
||||
**Fix:** Use `formatXFTPServer(server)` as cache key (includes keyHash). Already available in `protocol/address.ts:52-54`.
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
const key = "https://" + server.host + ":" + server.port
|
||||
|
||||
// After:
|
||||
const key = formatXFTPServer(server)
|
||||
```
|
||||
|
||||
Note: With the redesign in 3.5, the cache key fix is inherent — the `connections` Map uses `formatXFTPServer(server)` everywhere.
|
||||
|
||||
### 3.7 Phase 2: Multi-server upload with server selection and failover
|
||||
|
||||
**Problem:** Current upload (`agent.ts:105-157`) takes a single `server: XFTPServer` and uploads ALL chunks to it. The 12 preset servers (6 SimpleX + 6 Flux) are pointless — only one is ever used.
|
||||
|
||||
**Design goal:** Distribute chunks across servers. Retry FNEW on a different server if one fails. Once working servers are found, prefer them (heuristic: server unlikely to fail mid-process, more likely to be broken initially due to maintenance/downtime).
|
||||
|
||||
**Reference implementation:** Haskell `Agent.hs:457-486` (`createChunk` / `createWithNextSrv`) + `Client.hs:2335-2385` (`getNextServer_` / `withNextSrv`).
|
||||
|
||||
#### Haskell algorithm summary
|
||||
|
||||
Two-stage architecture:
|
||||
|
||||
1. **Allocate stage (serial per file in Haskell):** For each chunk, call FNEW on a randomly-selected server. If FNEW fails, pick a different server and retry. Track tried hosts to avoid retrying the same server. After all chunks are assigned to servers, spawn one upload worker per server.
|
||||
|
||||
2. **Upload stage (parallel per server):** Each server worker uploads its assigned chunks sequentially (FPUT). On FPUT failure, retry on the same server with backoff (because the chunk replica already exists on that server). No server failover for FPUT.
|
||||
|
||||
Server selection constraints (hierarchical, `getNextServer_` Client.hs:2335-2350):
|
||||
1. Prefer servers from unused operators (operator diversity)
|
||||
2. Prefer servers with unused hosts (host diversity)
|
||||
3. Random pick from the most-constrained candidate set
|
||||
4. If all exhausted, reset tried set and start over
|
||||
|
||||
#### Web client adaptation
|
||||
|
||||
The web client doesn't have operators or a database. Simplified algorithm with two stages:
|
||||
|
||||
**Stage 1 — Allocate:** Create chunk records on servers (FNEW). Unlike Haskell which is serial here, web FNEW runs concurrently within a concurrency limit. FNEW is a small command — concurrent FNEW on the same connection is not a problem, and concurrent FNEW across servers improves upload startup time.
|
||||
|
||||
**Stage 2 — Upload:** Upload chunk data (FPUT). Parallel across servers, sequential per server (reuses per-server queues from 3.5). FPUT retries on the same server with backoff — no server rotation because the chunk replica already exists on that server. Stage 2 reads chunk data by offset (via `readChunk`), so `SentChunk` must be extended with `chunkOffset: number` (from ChunkSpec).
|
||||
|
||||
```typescript
|
||||
interface UploadState {
|
||||
untriedServers: XFTPServer[] // servers not yet attempted — initially all servers
|
||||
workingServers: XFTPServer[] // servers that succeeded FNEW
|
||||
}
|
||||
|
||||
const MAX_FNEW_ATTEMPTS = 5 // per chunk: try up to 5 different servers
|
||||
|
||||
async function uploadFile(
|
||||
agent: XFTPClientAgent,
|
||||
allServers: XFTPServer[],
|
||||
encrypted: EncryptedFileMetadata,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResult> {
|
||||
const state: UploadState = {untriedServers: [...allServers], workingServers: []}
|
||||
const specs = prepareChunkSpecs(encrypted.chunkSizes)
|
||||
const concurrency = options?.concurrency ?? 4
|
||||
|
||||
// Stage 1: Allocate — concurrent FNEW within concurrency limit
|
||||
const sentChunks: SentChunk[] = new Array(specs.length)
|
||||
const queue = specs.map((spec, i) => ({spec, chunkNo: i + 1, index: i}))
|
||||
let idx = 0
|
||||
async function allocateWorker() {
|
||||
while (idx < queue.length) {
|
||||
const item = queue[idx++]
|
||||
const {server, chunk} = await createChunkWithFailover(
|
||||
agent, allServers, state, concurrency, item.spec, item.chunkNo
|
||||
)
|
||||
sentChunks[item.index] = chunk
|
||||
}
|
||||
}
|
||||
const allocateWorkers = Array.from(
|
||||
{length: Math.min(concurrency, queue.length)},
|
||||
() => allocateWorker()
|
||||
)
|
||||
await Promise.all(allocateWorkers)
|
||||
|
||||
// Stage 2: Upload — parallel across servers, sequential per server
|
||||
// readChunk reads from the encrypted file by offset (same as Phase 1 uploadFile)
|
||||
let uploaded = 0
|
||||
const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)
|
||||
const byServer = groupBy(sentChunks, c => formatXFTPServer(c.server))
|
||||
await Promise.all([...byServer.entries()].map(async ([srvKey, chunks]) => {
|
||||
for (const chunk of chunks) {
|
||||
const chunkData = await readChunk(chunk.chunkOffset, chunk.chunkSize)
|
||||
await uploadXFTPChunk(agent, chunk.server, chunk.senderKey, chunk.senderId, chunkData)
|
||||
uploaded += chunk.chunkSize
|
||||
options?.onProgress?.(uploaded, total)
|
||||
}
|
||||
}))
|
||||
|
||||
return buildDescriptions(encrypted, sentChunks)
|
||||
}
|
||||
```
|
||||
|
||||
**`createChunkWithFailover`** — server selection with per-chunk retry limit:
|
||||
|
||||
```typescript
|
||||
async function createChunkWithFailover(
|
||||
agent: XFTPClientAgent,
|
||||
allServers: XFTPServer[],
|
||||
state: UploadState,
|
||||
concurrency: number,
|
||||
spec: ChunkSpec,
|
||||
chunkNo: number
|
||||
): Promise<{server: XFTPServer, chunk: SentChunk}> {
|
||||
const maxAttempts = Math.min(allServers.length, MAX_FNEW_ATTEMPTS)
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const server = pickServer(allServers, state, concurrency)
|
||||
try {
|
||||
const chunk = await createAndPrepareChunk(agent, server, spec, chunkNo)
|
||||
// Success — add to working set (if not already there)
|
||||
if (!state.workingServers.some(s => formatXFTPServer(s) === formatXFTPServer(server))) {
|
||||
state.workingServers.push(server)
|
||||
}
|
||||
return {server, chunk}
|
||||
} catch (e) {
|
||||
// Remove from working if it was there
|
||||
state.workingServers = state.workingServers.filter(
|
||||
s => formatXFTPServer(s) !== formatXFTPServer(server)
|
||||
)
|
||||
if (attempt === maxAttempts - 1) throw e
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
```
|
||||
|
||||
**`pickServer`** — two-list selection:
|
||||
|
||||
```typescript
|
||||
function pickServer(
|
||||
allServers: XFTPServer[],
|
||||
state: UploadState,
|
||||
concurrency: number
|
||||
): XFTPServer {
|
||||
// Once enough working servers found, only use those
|
||||
if (state.workingServers.length >= concurrency) {
|
||||
return randomPick(state.workingServers)
|
||||
}
|
||||
// Still exploring — pick from untried
|
||||
if (state.untriedServers.length > 0) {
|
||||
const idx = Math.floor(Math.random() * state.untriedServers.length)
|
||||
return state.untriedServers.splice(idx, 1)[0] // remove from untried
|
||||
}
|
||||
// All tried — reset untried to non-working servers and retry
|
||||
state.untriedServers = allServers.filter(
|
||||
s => !state.workingServers.some(w => formatXFTPServer(w) === formatXFTPServer(s))
|
||||
)
|
||||
if (state.untriedServers.length > 0) {
|
||||
const idx = Math.floor(Math.random() * state.untriedServers.length)
|
||||
return state.untriedServers.splice(idx, 1)[0]
|
||||
}
|
||||
// Every server is working — pick any working
|
||||
return randomPick(state.workingServers)
|
||||
}
|
||||
```
|
||||
|
||||
**Algorithm:** Two lists — `untriedServers` (initially all) and `workingServers` (initially empty). When `workingServers.length < concurrency`, pick from `untriedServers` (removing on pick). On FNEW success, add to `workingServers`. On FNEW failure, server is already removed from `untriedServers`; remove from `workingServers` if present. When `untriedServers` is empty, reset it to all non-working servers. Once `workingServers.length >= concurrency`, pick randomly only from `workingServers`.
|
||||
|
||||
**Termination condition:** Each chunk tries at most `min(serverCount, 5)` different servers. If all attempts fail, the chunk fails and the upload fails with the last error. Rationale: if 5 out of 12 servers are down, something systemic is wrong and continuing is unlikely to help. Timeouts count as failures — the timed-out server is removed from working and a different server is picked next.
|
||||
|
||||
**Key differences from Haskell:**
|
||||
- No operator concept — just host diversity via random selection
|
||||
- No database — state tracked in-memory during upload
|
||||
- FNEW runs concurrently (Haskell is serial) — improves startup time
|
||||
- FNEW is cheap and retried with server rotation; FPUT retries on same server
|
||||
|
||||
**Download changes (also Phase 2):** Default concurrency should be 4 (matching Haskell). Download already groups by server in 3.5. If `replicas[0]` download fails, try `replicas[1]`, `replicas[2]`, etc. (fallback across replicas).
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Error handling and connection resilience
|
||||
|
||||
Steps are ordered by dependency and should be implemented one by one.
|
||||
|
||||
#### Step 1: Fix cache key (3.6)
|
||||
- Change cache key to `formatXFTPServer(server)` in `getXFTPServerClient` and `closeXFTPServerClient`
|
||||
- Add import for `formatXFTPServer`
|
||||
- Run existing tests to verify no regression
|
||||
|
||||
#### Step 2: Typed error detection for padded server errors (3.2 client-side)
|
||||
- Add `XFTPRetriableError` class
|
||||
- In `sendXFTPCommand`, detect padded error strings before `decodeTransmission`
|
||||
- Classify `FRErr` responses as retriable or permanent with human-readable messages
|
||||
- Run existing tests
|
||||
|
||||
#### Step 3: Fetch timeout (3.3)
|
||||
- Add `TransportConfig` with `timeoutMs`
|
||||
- Thread config through `createTransport` → `connectXFTP` → command wrappers
|
||||
- Add `AbortController` to browser `fetch()` and `setTimeout` to Node.js HTTP/2
|
||||
- Add vitest test: timeout triggers after configured duration
|
||||
- Run existing tests
|
||||
|
||||
#### Step 4: Connection state with Promise-based lock and per-server queues (3.5)
|
||||
- Introduce `ServerConnection` record: `{client: Promise<XFTPClient>, queue: Promise<void>}`
|
||||
- Replace `XFTPClientAgent.clients: Map<string, XFTPClient>` with `connections: Map<string, ServerConnection>`
|
||||
- Implement `reconnectClient` — replaces `conn.client` with new promise, preserves queue
|
||||
- Implement `enqueueCommand` — chains operation onto server's queue
|
||||
- Implement `removeStaleConnection` — removes entry only if current promise is the failed one
|
||||
- Auto-cleanup: `p.catch(() => delete)` removes failed connections so next caller starts fresh
|
||||
- Adapt `closeXFTPServerClient` and `closeXFTPAgent`
|
||||
- Add vitest tests:
|
||||
- Concurrent calls to same server produce single connection
|
||||
- Failed promise is cleaned up, next caller gets fresh connection
|
||||
|
||||
#### Step 5: Automatic retry in sendXFTPCommand (3.2)
|
||||
- Add retry loop with reconnect
|
||||
- Change `sendXFTPCommand` signature: takes `agent + server` instead of `client`; export it (needed by tests and by agent.ts callers)
|
||||
- Rename current `sendXFTPCommand` → `sendXFTPCommandOnce` (private); add padded error detection + FRErr classification (throw `XFTPRetriableError` for SESSION/HANDSHAKE, `XFTPPermanentError` for AUTH/NO_FILE/etc.)
|
||||
- All command wrappers (`createXFTPChunk`, `uploadXFTPChunk`, etc.) pass agent + server
|
||||
- Update agent.ts call sites: remove `getXFTPServerClient` calls before command wrappers (in `uploadFile`, `uploadRedirectDescription`, `downloadFileRaw`, `resolveRedirect`, `deleteFile`)
|
||||
- Max 3 retries for retriable errors, immediate throw for permanent
|
||||
- On retriable error: call `reconnectClient` and retry. On retriable error exhausted: call `removeStaleConnection` to clean up. On permanent error: throw immediately without touching connection
|
||||
- Add vitest tests:
|
||||
- Server started with delay → first attempt fails, retry succeeds
|
||||
- 3 retries exhausted → error propagates with human-readable message
|
||||
- Non-retriable error (AUTH) → no retry, immediate failure
|
||||
|
||||
#### Step 6: Server-side stale session handling (3.1)
|
||||
- Add one guard to `Nothing` branch: `sniUsed && not webHello -> throwE SESSION`
|
||||
- Remove debug `hPutStrLn stderr` lines (all 6 occurrences in dispatch)
|
||||
- All other branches unchanged
|
||||
- Run Haskell tests + Playwright tests
|
||||
|
||||
#### Step 7: Download with per-server grouping
|
||||
- Modify `downloadFileRaw` to group chunks by server, sequential within each server (`for` loop), parallel across servers (`Promise.all`)
|
||||
- Add vitest test: concurrent downloads from different servers run in parallel
|
||||
|
||||
#### Step 8: UI error improvements (3.4)
|
||||
- Temporary errors: auto-retry loop (3 attempts), then show human-readable diagnosis + manual retry button
|
||||
- Permanent errors: show human-readable error, NO retry button
|
||||
- Manual retry resumes from last successful chunk (not full restart)
|
||||
|
||||
#### Step 9: Remove debug logging
|
||||
- Remove all `console.log('[DEBUG ...]')` and `hPutStrLn stderr "DEBUG ..."` lines
|
||||
- Keep `console.error('[XFTP] ...')` error logging
|
||||
|
||||
### Phase 2: Multi-server upload
|
||||
|
||||
Implement after Phase 1 is complete and tested.
|
||||
|
||||
#### Step 10: Multi-server upload with failover (3.7)
|
||||
- Extend `SentChunk` with `chunkOffset: number` (from ChunkSpec) and `server: XFTPServer` (assigned during allocate) — Stage 2 reads data by offset and groups chunks by server
|
||||
- Change `uploadFile` signature: takes `allServers: XFTPServer[]` instead of single `server`
|
||||
- Implement `UploadState` with `untriedServers` and `workingServers`
|
||||
- Implement `createChunkWithFailover` and `pickServer`: two-list selection (untried → working once enough found), max `min(serverCount, 5)` attempts per chunk
|
||||
- Allocate stage: concurrent FNEW within concurrency limit (default 4)
|
||||
- Upload stage: parallel across servers, sequential per server (reuse queue from Step 7)
|
||||
- Update `web/upload.ts`: pass `getServers()` instead of `pickRandomServer(getServers())`
|
||||
- Update description building: each chunk references its actual server
|
||||
- Add vitest tests:
|
||||
- File split across N servers (verify different servers in description)
|
||||
- One server down → chunks redistributed to others
|
||||
- All servers down → error after exhausting 5 attempts per chunk
|
||||
|
||||
#### Step 11: Download concurrency and replica fallback
|
||||
- Change default download concurrency from 1 to 4
|
||||
- If `replicas[0]` download fails, try `replicas[1]`, `replicas[2]`, etc.
|
||||
- Uses per-server queues from Step 7
|
||||
|
||||
## 5. Testing Plan
|
||||
|
||||
### Principle
|
||||
|
||||
Prefer low-level vitest tests over Playwright E2E. Each new function gets one focused test. Pure functions tested without mocks; connection management tested with mock `connectXFTP`; server behavior tested with real server. Total: 13 tests across 4 files.
|
||||
|
||||
Tests A-C run in browser context (`@vitest/browser` with Chromium headless), configured in `vitest.config.ts`. Test D (integration) requires a separate Node.js vitest config since it uses `node:http2`. Existing `globalSetup.ts` provides a real XFTP server for integration tests.
|
||||
|
||||
### Test file A: `test/errors.test.ts` — pure, no server
|
||||
|
||||
Tests error classification and padded error detection (Steps 2, 5).
|
||||
|
||||
**T1. `isRetriable` classifies errors correctly**
|
||||
```typescript
|
||||
// Retriable:
|
||||
expect(isRetriable(new XFTPRetriableError("SESSION"))).toBe(true)
|
||||
expect(isRetriable(new XFTPRetriableError("HANDSHAKE"))).toBe(true)
|
||||
expect(isRetriable(new TypeError("fetch failed"))).toBe(true) // network error
|
||||
expect(isRetriable(Object.assign(new Error(), {name: "AbortError"}))).toBe(true) // timeout
|
||||
// Not retriable:
|
||||
expect(isRetriable(new XFTPPermanentError("AUTH", "..."))).toBe(false)
|
||||
expect(isRetriable(new XFTPPermanentError("NO_FILE", "..."))).toBe(false)
|
||||
expect(isRetriable(new XFTPPermanentError("INTERNAL", "..."))).toBe(false)
|
||||
```
|
||||
|
||||
**T2. `categorizeError` produces human-readable messages**
|
||||
```typescript
|
||||
// categorizeError receives thrown errors (from sendXFTPCommandOnce or transport)
|
||||
const e = categorizeError(new XFTPPermanentError("AUTH", "File is invalid, expired, or has been removed"))
|
||||
expect(e.message).toContain("expired")
|
||||
// Verify every permanent error type maps to a non-empty human-readable message
|
||||
for (const errType of ["AUTH", "NO_FILE", "SIZE", "QUOTA", "BLOCKED", "DIGEST", "INTERNAL"]) {
|
||||
expect(humanReadableMessage({type: errType}).length).toBeGreaterThan(0)
|
||||
}
|
||||
// Retriable errors also get human-readable messages after exhaustion
|
||||
const re = categorizeError(new XFTPRetriableError("SESSION"))
|
||||
expect(re.message).toContain("expired") // "Session expired, reconnecting..."
|
||||
```
|
||||
|
||||
**T3. Padded error detection extracts error string from padded block**
|
||||
```typescript
|
||||
import {blockPad, blockUnpad} from '../src/protocol/transmission.js'
|
||||
// Simulate server sending padded "SESSION"
|
||||
const padded = blockPad(new TextEncoder().encode("SESSION"))
|
||||
const raw = blockUnpad(padded)
|
||||
expect(raw.length).toBeLessThan(20)
|
||||
expect(new TextDecoder().decode(raw)).toBe("SESSION")
|
||||
// Normal transmission block (batch count + large-encoded data) is NOT a short string
|
||||
const sessionId = new Uint8Array(32) // dummy
|
||||
const normalBlock = encodeTransmission(sessionId, new Uint8Array(0), new Uint8Array(0), encodePING())
|
||||
const normalRaw = blockUnpad(normalBlock)
|
||||
expect(normalRaw.length).toBeGreaterThan(20) // not mistaken for padded error
|
||||
```
|
||||
|
||||
### Test file B: `test/connection.test.ts` — mock connectXFTP, no server
|
||||
|
||||
Tests connection management functions (Steps 4, 5). Uses `vi.mock` to replace `connectXFTP` with a controllable promise factory.
|
||||
|
||||
**T4. `getXFTPServerClient` coalesces concurrent calls**
|
||||
```typescript
|
||||
// Mock connectXFTP to return a deferred promise
|
||||
const {promise, resolve} = promiseWithResolvers<XFTPClient>()
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(promise)
|
||||
const agent = newXFTPAgent()
|
||||
const p1 = getXFTPServerClient(agent, server)
|
||||
const p2 = getXFTPServerClient(agent, server)
|
||||
expect(p1).toBe(p2) // same promise, single connection
|
||||
resolve(mockClient)
|
||||
expect(await p1).toBe(mockClient)
|
||||
```
|
||||
|
||||
**T5. `getXFTPServerClient` auto-cleans failed connections**
|
||||
```typescript
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(Promise.reject(new Error("down")))
|
||||
const agent = newXFTPAgent()
|
||||
const p1 = getXFTPServerClient(agent, server)
|
||||
await expect(p1).rejects.toThrow("down")
|
||||
// After microtask, entry is removed
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(agent.connections.has(formatXFTPServer(server))).toBe(false)
|
||||
// Next call creates fresh connection
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(Promise.resolve(mockClient))
|
||||
const p2 = getXFTPServerClient(agent, server)
|
||||
expect(p2).not.toBe(p1)
|
||||
```
|
||||
|
||||
**T6. `removeStaleConnection` respects promise identity**
|
||||
```typescript
|
||||
const agent = newXFTPAgent()
|
||||
const p1 = Promise.resolve(mockClient)
|
||||
agent.connections.set(key, {client: p1, queue: Promise.resolve()})
|
||||
// Replace with reconnect
|
||||
const p2 = Promise.resolve(mockClient2)
|
||||
agent.connections.set(key, {client: p2, queue: Promise.resolve()})
|
||||
// removeStaleConnection with old promise does NOT remove new entry
|
||||
removeStaleConnection(agent, server, p1)
|
||||
expect(agent.connections.has(key)).toBe(true)
|
||||
expect(agent.connections.get(key)!.client).toBe(p2)
|
||||
// removeStaleConnection with current promise removes it
|
||||
removeStaleConnection(agent, server, p2)
|
||||
expect(agent.connections.has(key)).toBe(false)
|
||||
```
|
||||
|
||||
**T7. `reconnectClient` replaces promise but preserves queue**
|
||||
```typescript
|
||||
const agent = newXFTPAgent()
|
||||
const origQueue = Promise.resolve()
|
||||
agent.connections.set(key, {client: Promise.resolve(mockClient), queue: origQueue})
|
||||
vi.mocked(connectXFTP).mockReturnValueOnce(Promise.resolve(mockClient2))
|
||||
reconnectClient(agent, server)
|
||||
const conn = agent.connections.get(key)!
|
||||
expect(await conn.client).toBe(mockClient2) // new client
|
||||
expect(conn.queue).toBe(origQueue) // queue preserved
|
||||
```
|
||||
|
||||
**T8. Retry loop: retriable error triggers reconnect, permanent error does not**
|
||||
|
||||
Mock approach: `vi.mock('../src/client.js')` to mock `connectXFTP` (exported). `reconnectClient` is not exported — its behavior is controlled indirectly via `connectXFTP` mock (it calls `connectXFTP` internally). Verify retry count via `connectXFTP` call count. Note: vitest module mocking may need adjustment depending on ESM transform behavior — if intra-module calls bypass the mock, extract `connectXFTP` to a separate module or use dependency injection for testing.
|
||||
|
||||
```typescript
|
||||
// Script: first connectXFTP returns client whose post throws retriable,
|
||||
// second connectXFTP (from reconnect) returns client whose post succeeds
|
||||
vi.mocked(connectXFTP)
|
||||
.mockResolvedValueOnce({
|
||||
...mockClient,
|
||||
transport: { post: async () => { throw new XFTPRetriableError("SESSION") }, close: () => {} }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...mockClient,
|
||||
transport: { post: async () => okResponseBlock, close: () => {} }
|
||||
})
|
||||
|
||||
const agent = newXFTPAgent()
|
||||
const result = await sendXFTPCommand(agent, server, dummyKey, dummyId, encodePING())
|
||||
expect(result.response.type).toBe("FROk")
|
||||
expect(vi.mocked(connectXFTP)).toHaveBeenCalledTimes(2) // initial + 1 reconnect
|
||||
|
||||
// Reset — all 3 retries exhausted: connectXFTP called 3 times (initial + 2 reconnects)
|
||||
vi.mocked(connectXFTP).mockClear()
|
||||
vi.mocked(connectXFTP).mockResolvedValue({
|
||||
...mockClient,
|
||||
transport: { post: async () => { throw new XFTPRetriableError("SESSION") }, close: () => {} }
|
||||
})
|
||||
const agent2 = newXFTPAgent()
|
||||
await expect(sendXFTPCommand(agent2, server, dummyKey, dummyId, encodePING()))
|
||||
.rejects.toThrow(/reconnecting|expired/)
|
||||
expect(vi.mocked(connectXFTP)).toHaveBeenCalledTimes(3) // initial + 2 reconnects
|
||||
|
||||
// Reset — permanent error: connectXFTP called once (initial only, no reconnect)
|
||||
vi.mocked(connectXFTP).mockClear()
|
||||
vi.mocked(connectXFTP).mockResolvedValue({
|
||||
...mockClient,
|
||||
transport: { post: async () => authErrorBlock, close: () => {} }
|
||||
})
|
||||
const agent3 = newXFTPAgent()
|
||||
await expect(sendXFTPCommand(agent3, server, dummyKey, dummyId, encodePING()))
|
||||
.rejects.toThrow(/expired/)
|
||||
expect(vi.mocked(connectXFTP)).toHaveBeenCalledTimes(1) // initial only, no reconnect
|
||||
```
|
||||
|
||||
### Test file C: `test/server-selection.test.ts` — pure, no server
|
||||
|
||||
Tests `pickServer` state machine (Step 10). Determinism: seed `Math.random` or test invariants not specific picks.
|
||||
|
||||
**T9. `pickServer` picks from untried when working < concurrency**
|
||||
```typescript
|
||||
const servers = [s1, s2, s3, s4, s5]
|
||||
const state: UploadState = {untriedServers: [...servers], workingServers: []}
|
||||
const picked = pickServer(servers, state, 4)
|
||||
// picked is from untried, and was removed from untried
|
||||
expect(state.untriedServers.length).toBe(4)
|
||||
expect(state.untriedServers).not.toContainEqual(picked)
|
||||
```
|
||||
|
||||
**T10. `pickServer` picks only from working when working >= concurrency**
|
||||
```typescript
|
||||
const state: UploadState = {
|
||||
untriedServers: [s5], // still has untried
|
||||
workingServers: [s1, s2, s3, s4]
|
||||
}
|
||||
const picked = pickServer(servers, state, 4)
|
||||
// Must pick from working, NOT from untried
|
||||
expect([s1, s2, s3, s4]).toContainEqual(picked)
|
||||
expect(state.untriedServers.length).toBe(1) // untried unchanged
|
||||
```
|
||||
|
||||
**T11. `pickServer` resets untried when exhausted**
|
||||
```typescript
|
||||
const state: UploadState = {
|
||||
untriedServers: [], // all tried
|
||||
workingServers: [s1, s2] // only 2 working, concurrency=4
|
||||
}
|
||||
const picked = pickServer(servers, state, 4)
|
||||
// Should have reset untried to non-working servers and picked from them
|
||||
expect([s3, s4, s5]).toContainEqual(picked)
|
||||
expect(state.untriedServers.length).toBe(2) // 3 non-working minus 1 picked
|
||||
```
|
||||
|
||||
### Test file D: `test/integration.test.ts` — real server, Node.js mode
|
||||
|
||||
Requires separate vitest config with `browser: {enabled: false}` since these tests use `node:http2` directly. Alternatively, add `test/vitest.node.config.ts` that includes only `test/integration.test.ts` and runs in Node.js.
|
||||
|
||||
**T12. Stale session returns padded SESSION error (requires Step 6)**
|
||||
```typescript
|
||||
import http2 from 'node:http2'
|
||||
// Connect and handshake normally via the client
|
||||
const client = await connectXFTP(server)
|
||||
// Create a raw HTTP/2 session (new TLS SessionId, no handshake state on server)
|
||||
const session = http2.connect(client.baseUrl, {rejectUnauthorized: false})
|
||||
// Build a dummy command block using the old client's sessionId.
|
||||
// Content doesn't matter — server detects stale session before parsing command.
|
||||
const dummyKey = new Uint8Array(64) // Ed25519 private key (dummy)
|
||||
const dummyId = new Uint8Array(24) // entity ID (dummy)
|
||||
const cmdBlock = encodeAuthTransmission(client.sessionId, new Uint8Array(0), dummyId, encodePING(), dummyKey)
|
||||
const resp = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
const req = session.request({":method": "POST", ":path": "/"})
|
||||
const chunks: Buffer[] = []
|
||||
req.on("data", (c: Buffer) => chunks.push(c))
|
||||
req.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
||||
req.on("error", reject)
|
||||
req.end(Buffer.from(cmdBlock))
|
||||
})
|
||||
// Server should return padded "SESSION" (not crash, not "HANDSHAKE")
|
||||
const raw = blockUnpad(resp.subarray(0, XFTP_BLOCK_SIZE))
|
||||
expect(new TextDecoder().decode(raw)).toBe("SESSION")
|
||||
session.close()
|
||||
closeXFTP(client)
|
||||
```
|
||||
|
||||
**T13. Fetch timeout fires within configured duration**
|
||||
```typescript
|
||||
// connectXFTP with 1ms timeout — handshake requires multiple round trips,
|
||||
// so even on localhost it will exceed 1ms and trigger abort
|
||||
await expect(
|
||||
connectXFTP(server, {timeoutMs: 1})
|
||||
).rejects.toThrow(/abort|timeout/i)
|
||||
```
|
||||
|
||||
### What existing tests already cover (no new tests needed)
|
||||
|
||||
| Behavior | Covered by |
|
||||
|----------|-----------|
|
||||
| Cache key fix (Step 1) | Existing round-trip test — uses `formatXFTPServer` after refactor |
|
||||
| Basic upload/download | 24 Playwright tests + 1 vitest browser test |
|
||||
| File size limits, unicode filenames | Playwright edge case tests |
|
||||
| Server startup/teardown | `globalSetup.ts` / `globalTeardown.ts` |
|
||||
| Handshake + identity verification | `connectXFTP` in existing round-trip test |
|
||||
|
||||
### Test ordering
|
||||
|
||||
Tests must be added alongside their implementation step:
|
||||
- **Step 2**: Add T1, T2, T3 (test/errors.test.ts)
|
||||
- **Step 3**: Add T13 (test/integration.test.ts) — requires Node.js vitest config
|
||||
- **Step 4**: Add T4, T5, T6, T7 (test/connection.test.ts)
|
||||
- **Step 5**: Add T8 (test/connection.test.ts)
|
||||
- **Step 6**: Add T12 (test/integration.test.ts) — requires server change + Node.js vitest config
|
||||
- **Step 10**: Add T9, T10, T11 (test/server-selection.test.ts)
|
||||
|
||||
## 6. Context for Implementation Sessions
|
||||
|
||||
### Files to re-read on session start
|
||||
|
||||
**TypeScript (xftp-web/src/):**
|
||||
- `client.ts` — `XFTPClient`, `XFTPClientAgent`, `getXFTPServerClient`, `closeXFTPServerClient`, `connectXFTP`, `sendXFTPCommand`, `createBrowserTransport`, `createNodeTransport`, all command wrappers
|
||||
- `agent.ts` — `uploadFile`, `downloadFileRaw`, `downloadFile`, `resolveRedirect`, `encryptFileForUpload`
|
||||
- `protocol/transmission.ts` — `encodeAuthTransmission`, `decodeTransmission`, `blockPad`, `blockUnpad`
|
||||
- `protocol/commands.ts` — `XFTPErrorType`, `FileResponse`, `decodeResponse`, `decodeXFTPError`
|
||||
- `protocol/handshake.ts` — `decodeServerHandshake` (padded error detection heuristic)
|
||||
- `protocol/address.ts` — `XFTPServer`, `parseXFTPServer`, `formatXFTPServer`
|
||||
- `web/upload.ts` — UI error handling, retry button
|
||||
- `web/download.ts` — UI error handling, retry button
|
||||
- `web/servers.ts` — `getServers`, `pickRandomServer`
|
||||
|
||||
**TypeScript (xftp-web/test/):**
|
||||
- `browser.test.ts` — vitest Node.js test template (uses real Haskell server)
|
||||
- `globalSetup.ts` — server startup, config generation, port file
|
||||
- `page.spec.ts` — Playwright page tests
|
||||
|
||||
**Haskell (reference for multi-server):**
|
||||
- `src/Simplex/FileTransfer/Agent.hs` — `createChunk` (lines 457-486, allocate stage), `runXFTPSndPrepareWorker` (lines 391-430, serial allocate in Haskell), `runXFTPSndWorker` (lines 494-548, per-server upload worker)
|
||||
- `src/Simplex/Messaging/Agent/Client.hs` — `getNextServer_` (lines 2335-2350), `withNextSrv` (lines 2366-2385), `pickServer` (lines 2309-2314)
|
||||
|
||||
**Haskell (server):**
|
||||
- `src/Simplex/FileTransfer/Server.hs` — `xftpServerHandshakeV1` (lines 165-244), `processRequest` (lines 403-435)
|
||||
- `src/Simplex/Messaging/Protocol.hs` — `tDecodeServer` (lines 2239-2265) — sessionId verification at line 2242
|
||||
|
||||
### Key design constraints
|
||||
|
||||
1. `tDecodeServer` (Protocol.hs:2242) verifies `sessId == sessionId` — commands signed with old sessionId WILL fail on new connection
|
||||
2. Server generates per-session DH key in `processHello` (Server.hs:207) — cannot be shared across sessions
|
||||
3. `fetch()` provides zero control over HTTP/2 connection reuse — browser decides
|
||||
4. `xftp-web-hello` header is only checked in dispatch (Server.hs:192), NOT inside `processHello`
|
||||
5. Handshake-phase errors are raw padded strings; command-phase errors are proper ERR transmissions
|
||||
6. Ed25519 signature verification (`TASignature` path, Protocol.hs:1314) does NOT use `thAuth` — but SMP will
|
||||
7. Reconnect must re-handshake to get new sessionId AND new server DH key
|
||||
8. The new `throwE SESSION` guard (Step 6) sends a raw padded "SESSION" string — no sessionId framing. Client detects this via padded error heuristic (section 3.2), not via sessionId mismatch
|
||||
9. FNEW is cheap (creates chunk record on server) — retry with different server on failure
|
||||
10. FPUT retries on same server (chunk replica already exists there) — close connection + backoff
|
||||
|
||||
## 7. Plan Maintenance
|
||||
|
||||
This plan must be updated as implementation proceeds:
|
||||
- Mark completed steps with date
|
||||
- Record any deviations from the plan with rationale
|
||||
- Add new issues discovered during implementation
|
||||
- Update file references if code moves
|
||||
@@ -0,0 +1,327 @@
|
||||
# CLI-Web Link Compatibility
|
||||
|
||||
## Problem
|
||||
|
||||
CLI and web clients are isolated: CLI outputs `.xftp` description files, web outputs
|
||||
`https://host/#<encoded>` links. A file uploaded via one cannot be downloaded via the other.
|
||||
|
||||
## Solution Summary
|
||||
|
||||
Make CLI produce and consume web-compatible links so that:
|
||||
- CLI `send` always outputs a web link (in addition to `.xftp` files)
|
||||
- CLI `recv` accepts a web link URL as input (alternative to `.xftp` file path)
|
||||
- Browser can download files uploaded by CLI and vice versa
|
||||
|
||||
The web page host is derived from the XFTP server address - the server that hosts the file
|
||||
also hosts the download page. Making XFTP servers actually serve the web page is a separate
|
||||
concern (not covered here), but the link format anticipates it.
|
||||
|
||||
The YAML file description format is already identical between CLI and web.
|
||||
The only gap is the URI encoding layer: DEFLATE-raw compression + base64url + URL structure.
|
||||
|
||||
## Current State
|
||||
|
||||
### Web link format
|
||||
|
||||
```
|
||||
https://<xftp-server-host>/#<base64url(deflateRaw(YAML))>
|
||||
```
|
||||
|
||||
Encoding chain (agent.ts:64-68):
|
||||
1. `encodeFileDescription(fd)` -> YAML string
|
||||
2. `TextEncoder.encode(yaml)` -> bytes
|
||||
3. `pako.deflateRaw(bytes)` -> compressed
|
||||
4. `base64urlEncode(compressed)` -> URI fragment (no `#`)
|
||||
|
||||
For multi-chunk files exceeding ~400 chars in URI, a redirect description is uploaded:
|
||||
the real file description is encrypted, uploaded as a separate XFTP file, and a smaller
|
||||
"redirect" description (pointing to it) is put in the URI.
|
||||
|
||||
### CLI file format
|
||||
|
||||
```
|
||||
xftp send FILE -> writes rcv1.xftp (raw YAML), snd.xftp.private
|
||||
xftp recv FILE.xftp -> reads raw YAML from file
|
||||
```
|
||||
|
||||
No URI support. No compression. No redirect descriptions.
|
||||
|
||||
### Existing Haskell `FileDescriptionURI`
|
||||
|
||||
`Description.hs:243-266` defines a `simplex:/file#/?desc=<URL-encoded raw YAML>` format.
|
||||
This is the SimpleX Chat app format - NOT the web page format. It uses URL-encoded raw YAML
|
||||
(no DEFLATE compression), and has a different URL structure.
|
||||
|
||||
## Detailed Tech Design
|
||||
|
||||
### 1. File Header (Filename) Compatibility
|
||||
|
||||
The filename is carried **inside the encrypted file data**, not in the file description YAML.
|
||||
Both CLI and web use the same `FileHeader` structure and binary encoding - full interop.
|
||||
|
||||
#### FileHeader type
|
||||
|
||||
Haskell (`Types.hs:36-46`):
|
||||
```haskell
|
||||
data FileHeader = FileHeader { fileName :: Text, fileExtra :: Maybe Text }
|
||||
instance Encoding FileHeader where
|
||||
smpEncode FileHeader {fileName, fileExtra} = smpEncode (fileName, fileExtra)
|
||||
```
|
||||
|
||||
TypeScript (`crypto/file.ts:11-24`):
|
||||
```typescript
|
||||
interface FileHeader { fileName: string; fileExtra: string | null }
|
||||
function encodeFileHeader(hdr: FileHeader): Uint8Array {
|
||||
return concatBytes(encodeString(hdr.fileName), encodeMaybe(encodeString, hdr.fileExtra))
|
||||
}
|
||||
```
|
||||
|
||||
Both produce identical binary: `[1-byte UTF-8 length][fileName bytes]['0']` (for null fileExtra).
|
||||
Max filename: 255 UTF-8 bytes (1-byte length prefix).
|
||||
|
||||
#### Encrypted file structure
|
||||
|
||||
Both CLI and web produce the same encrypted stream:
|
||||
```
|
||||
XSalsa20-Poly1305 encrypted:
|
||||
[8-byte Int64 fileSize] [FileHeader] [file content] ['#' padding]
|
||||
+ [16-byte auth tag]
|
||||
|
||||
Where fileSize = len(FileHeader) + len(file content)
|
||||
```
|
||||
|
||||
The 8-byte length prefix and padding are handled identically:
|
||||
- Haskell: `Crypto.hs:43-56` (`encryptFile`) / `Crypto.hs:81-87` (`decryptFirstChunk`)
|
||||
- TypeScript: `crypto/file.ts:51-70` (`encryptFile`) / `crypto/file.ts:81-94` (`decryptChunks`)
|
||||
|
||||
On decryption, `unPadLazy`/`splitLen` strips the 8-byte length prefix, then `parseFileHeader`
|
||||
extracts the filename from the remaining decrypted bytes (up to 1024 bytes examined, both sides).
|
||||
|
||||
#### CLI upload: sets real filename (ok)
|
||||
|
||||
`Client/Main.hs:246-247,273`:
|
||||
```haskell
|
||||
let (_, fileNameStr) = splitFileName filePath
|
||||
fileName = T.pack fileNameStr
|
||||
...
|
||||
fileHdr = smpEncode FileHeader {fileName, fileExtra = Nothing}
|
||||
```
|
||||
|
||||
Extracts the actual filename from the path and embeds it in the encrypted header.
|
||||
|
||||
#### CLI download: uses filename from header (ok)
|
||||
|
||||
`Crypto.hs:62-66` (single chunk) / `Crypto.hs:72-74` (multi-chunk):
|
||||
```haskell
|
||||
(FileHeader {fileName}, rest) <- parseFileHeader decryptedContent
|
||||
destFile <- withExceptT FTCEFileIOError $ getDestFile fileName
|
||||
```
|
||||
|
||||
`Client/Main.hs:435-441` (`getFilePath`):
|
||||
- If output dir specified: saves to `<dir>/<fileName>`
|
||||
- If no dir: saves to `~/Downloads/<fileName>`
|
||||
|
||||
The filename from the decrypted header determines the output file name.
|
||||
|
||||
#### Web upload: sets real filename (ok)
|
||||
|
||||
`upload.ts:121` -> `agent.ts:86`:
|
||||
```typescript
|
||||
const fileHdr = encodeFileHeader({fileName, fileExtra: null})
|
||||
```
|
||||
|
||||
Where `fileName` comes from `file.name` (browser File API).
|
||||
|
||||
#### Web download: uses filename from header (ok)
|
||||
|
||||
`download.ts:97,102`:
|
||||
```typescript
|
||||
const fileName = sanitizeFileName(header.fileName)
|
||||
a.download = encodeURIComponent(fileName)
|
||||
```
|
||||
|
||||
The web client additionally sanitizes the filename (strips path separators, control chars,
|
||||
bidi overrides, limits to 255 chars).
|
||||
|
||||
#### Web redirect description: empty filename (correct)
|
||||
|
||||
`agent.ts:193`: `encryptFileForUpload(yamlBytes, "")` - redirect descriptions use empty filename
|
||||
because they are internal artifacts, not user files. This is handled correctly on both sides:
|
||||
the redirect content is decrypted and parsed as YAML, not saved as a file.
|
||||
|
||||
#### Cross-client interop: fully compatible (ok)
|
||||
|
||||
| Scenario | Filename flow | Status |
|
||||
|----------|--------------|--------|
|
||||
| CLI upload -> CLI download | `splitFileName` -> header -> `getDestFile` | Works |
|
||||
| Web upload -> Web download | `File.name` -> header -> `sanitizeFileName` | Works |
|
||||
| CLI upload -> Web download | `splitFileName` -> header -> `sanitizeFileName` | **Compatible** |
|
||||
| Web upload -> CLI download | `File.name` -> header -> `getDestFile` | **Compatible** |
|
||||
|
||||
The binary encoding is identical (smpEncode). No changes needed for filename interop.
|
||||
The CLI should consider adding filename sanitization similar to the web client for safety.
|
||||
|
||||
### 2. Web Link Host Derivation
|
||||
|
||||
The web page URL domain comes from the XFTP server address, not from a CLI flag:
|
||||
|
||||
- **Non-redirected description**: use the server host of the first chunk's first replica.
|
||||
E.g., `xftp://abc=@xftp1.simplex.im` -> `https://xftp1.simplex.im/#<encoded>`
|
||||
|
||||
- **Redirected description**: use the server host of the redirect chunk (the outer description's
|
||||
chunk that stores the encrypted inner description).
|
||||
|
||||
The server address format is `xftp://<keyhash>@<host>[,<host2>,...][:<port>]`.
|
||||
The web link uses `https://<host>` (port 443 implied).
|
||||
|
||||
This means the CLI does not need a `--web-url` flag - the server address fully determines
|
||||
the link. The XFTP server serving the web page is a separate deployment concern.
|
||||
|
||||
### 3. Web URI Encoding/Decoding in Haskell
|
||||
|
||||
Add two functions (new module or in `Description.hs`):
|
||||
|
||||
```haskell
|
||||
-- Encode file description as web URI fragment (no leading #)
|
||||
encodeWebURI :: FileDescription 'FRecipient -> ByteString
|
||||
-- 1. Y.encode . encodeFileDescription -> YAML bytes
|
||||
-- 2. deflateRaw (raw DEFLATE, no zlib/gzip header) via zlib package
|
||||
-- 3. base64url encode (with padding, matching Data.ByteString.Base64.URL)
|
||||
|
||||
-- Decode web URI fragment (no leading #) to file description
|
||||
decodeWebURI :: ByteString -> Either String (ValidFileDescription 'FRecipient)
|
||||
-- 1. base64url decode
|
||||
-- 2. inflateRaw (raw DEFLATE decompress)
|
||||
-- 3. Y.decodeEither' -> YAMLFileDescription -> FileDescription
|
||||
-- 4. validateFileDescription
|
||||
|
||||
-- Build full web link from file description
|
||||
-- Extracts server host from first chunk replica (or redirect chunk)
|
||||
fileWebLink :: FileDescription 'FRecipient -> (String, ByteString)
|
||||
-- Returns (webHost, uriFragment)
|
||||
-- Caller assembles: "https://" <> webHost <> "/#" <> uriFragment
|
||||
```
|
||||
|
||||
**Dependency**: Add `zlib` to `simplexmq.cabal` (for raw DEFLATE).
|
||||
The codebase already has `zstd` for message compression - `zlib` is standard and small.
|
||||
|
||||
The `zlib` Haskell package provides `Codec.Compression.Zlib.Raw` for raw DEFLATE
|
||||
(no header/trailer), matching `pako.deflateRaw()` / `pako.inflateRaw()`.
|
||||
|
||||
### 4. Redirect Description Support
|
||||
|
||||
The CLI currently does NOT create redirect descriptions. For single-server single-recipient
|
||||
uploads, most file descriptions fit in a reasonable URI even for multi-chunk files. But for
|
||||
large files (many chunks x long server hostnames), the URI can exceed practical limits.
|
||||
|
||||
**Approach**: Match the web client threshold.
|
||||
- After encoding the URI, if `length > 400` and chunks > 1, upload a redirect description.
|
||||
- The redirect upload uses the same XFTP upload flow: encrypt YAML -> upload as file -> create
|
||||
outer description pointing to it.
|
||||
- This matches `agent.ts:152-155` exactly.
|
||||
- The redirect chunk's server becomes the web link host.
|
||||
|
||||
For CLI download from a redirect URI, the existing `cliReceiveFile` needs extension:
|
||||
- After decoding the file description, check `redirect` field.
|
||||
- If present: download and decrypt the redirect chunks first to get the inner description,
|
||||
then download the actual file using the inner description.
|
||||
- The web client already does this (`resolveRedirect` in agent.ts:320-346).
|
||||
|
||||
### 5. CLI Command Changes
|
||||
|
||||
#### `xftp send` - always output web link
|
||||
|
||||
```
|
||||
xftp send FILE [DIR] [-n COUNT] [-s SERVERS]
|
||||
```
|
||||
|
||||
- Upload file as usual
|
||||
- Generate web link: `https://<server-host>/#<encodeWebURI(rcvDescription)>`
|
||||
- If URI exceeds threshold, upload redirect description first
|
||||
- Print web link to stdout (in addition to `.xftp` file paths)
|
||||
- Only generates link for the first recipient (web links are single-recipient)
|
||||
|
||||
**Output change**:
|
||||
```
|
||||
Sender file description: ./file.xftp/snd.xftp.private
|
||||
Pass file descriptions to the recipient(s):
|
||||
./file.xftp/rcv1.xftp
|
||||
|
||||
Web link:
|
||||
https://xftp1.simplex.im/#eJy0VduO2zYQ...
|
||||
```
|
||||
|
||||
#### `xftp recv` - accept URL as input
|
||||
|
||||
```
|
||||
xftp recv <FILE.xftp | URL> [DIR]
|
||||
```
|
||||
|
||||
- If input starts with `http://` or `https://`, extract hash fragment after `#`
|
||||
- Decode: base64url -> inflateRaw -> YAML -> FileDescription
|
||||
- Resolve redirect if present
|
||||
- Download and decrypt as usual
|
||||
|
||||
The URL must be quoted on the command line (`"https://...#..."`) because `#` is a shell
|
||||
comment character when unquoted.
|
||||
|
||||
Implementation: modify `receiveP` parser to accept URL, add `decodeWebURI` path in
|
||||
`cliReceiveFile` alongside existing `getFileDescription'`.
|
||||
|
||||
### 6. YAML Format Compatibility
|
||||
|
||||
Already identical. The web `description.ts` explicitly matches Haskell `Data.Yaml` output:
|
||||
- Same field names (alphabetical key order)
|
||||
- Same base64url encoding for binary fields (with `=` padding)
|
||||
- Same server replica colon-delimited format: `chunkNo:replicaId:replicaKey[:digest][:chunkSize]`
|
||||
- Same size encoding (`kb`/`mb`/`gb` suffixes)
|
||||
- Same redirect structure
|
||||
|
||||
**Verification**: The Playwright test suite already tests upload->download round-trips.
|
||||
Adding a cross-client test (CLI upload -> web download, or web upload -> CLI download) would
|
||||
validate interop end-to-end.
|
||||
|
||||
### 7. Server Compatibility
|
||||
|
||||
No server changes needed. Both clients use the same XFTP protocol (FGET, FPUT, FNEW, FACK, FDEL).
|
||||
The web client adds `xftp-web-hello: 1` header for the hello handshake, but the actual file
|
||||
operations are identical wire-format.
|
||||
|
||||
The only consideration: CLI uses native HTTP/2 (via `http2` Haskell package), web uses
|
||||
browser `fetch()` API over HTTP/2. Both produce identical XFTP protocol frames.
|
||||
|
||||
**Note**: Making XFTP servers actually serve the web download page at `https://<host>/` is a
|
||||
separate deployment/infrastructure task. This plan only establishes the link format convention
|
||||
so that links are ready to work once servers serve the page.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Web URI codec in Haskell
|
||||
|
||||
1. Add `zlib` dependency to `simplexmq.cabal`
|
||||
2. Add `encodeWebURI` / `decodeWebURI` / `fileWebLink` to `Simplex.FileTransfer.Description`
|
||||
(or a new `Simplex.FileTransfer.Description.WebURI` module)
|
||||
3. `fileWebLink` extracts host from first chunk's first replica server address
|
||||
4. Add unit tests: encode a known FileDescription, verify output matches web client encoding
|
||||
5. Add round-trip test: encode -> decode -> compare
|
||||
|
||||
### Phase 2: CLI `recv` accepts URL
|
||||
|
||||
1. Modify `ReceiveOptions` to accept `Either FilePath WebURL` for `fileDescription`
|
||||
2. In `cliReceiveFile`: if URL, extract fragment after `#`, call `decodeWebURI`
|
||||
3. Add redirect resolution: if `redirect /= Nothing`, download redirect chunks,
|
||||
decrypt, parse inner description, then proceed with download
|
||||
4. Test: upload via web page -> copy link -> `xftp recv <link>`
|
||||
|
||||
### Phase 3: CLI `send` outputs web link
|
||||
|
||||
1. After upload, call `fileWebLink` to get (host, fragment)
|
||||
2. If fragment exceeds threshold, upload redirect description first, rebuild link
|
||||
3. Print `https://<host>/#<fragment>` to stdout
|
||||
4. Test: `xftp send FILE` -> open link in browser -> download
|
||||
|
||||
### Phase 4: Cross-client integration test
|
||||
|
||||
1. Add test: CLI send -> extract link from stdout -> Playwright browser download -> verify
|
||||
2. Add test: Playwright browser upload -> extract link -> CLI recv -> verify
|
||||
3. These can be shell-script or Haskell test-suite tests that spawn both clients
|
||||
@@ -0,0 +1,415 @@
|
||||
# Fix subQ deadlock: blocking writeTBQueue inside connLock
|
||||
|
||||
## Problem
|
||||
|
||||
Users report that message reception silently and permanently stops across all connections, with no error alerts. The app appears functional but no messages arrive. Recovery requires restart.
|
||||
|
||||
Root cause: a deadlock between worker threads holding `connLock` and the `agentSubscriber` (sole `subQ` reader).
|
||||
|
||||
### The deadlock mechanism
|
||||
|
||||
`subQ` (`TBQueue ATransmission`, capacity 4096 on mobile / 1024 on desktop) is the single pipeline between the agent layer and the chat layer. The `agentSubscriber` thread (`Commands.hs:4373`) is its **sole reader**.
|
||||
|
||||
Three code sites hold `connLock` and call blocking `writeTBQueue subQ` without a fullness check. When `subQ` is full, these block while holding the lock. If `agentSubscriber` simultaneously needs the same `connLock` (via `sendMessagesB_` → `withConnLocks`), it blocks too — creating a circular wait:
|
||||
|
||||
- **Worker**: holds `connLock(X)`, waits for `subQ` space (needs `agentSubscriber` to read)
|
||||
- **agentSubscriber**: sole `subQ` reader, waits for `connLock(X)` (needs worker to release)
|
||||
- **Result**: permanent silent deadlock — no exception, no alert, all connections blocked
|
||||
|
||||
### Confirmed deadlock scenarios
|
||||
|
||||
**Scenario 1**: Delivery worker during queue rotation test
|
||||
|
||||
```
|
||||
Delivery worker: agentSubscriber (sole subQ reader):
|
||||
withConnLock(X) [2187] readTBQueue subQ → processAgentMessageConn
|
||||
...DB operations... → sendPendingGroupMessages (on CON/SENT/QCONT)
|
||||
notify → writeTBQueue subQ [2238] → batchSendConnMessages → deliverMessagesB
|
||||
[BLOCKED — subQ full] → withAgent sendMessagesB [synchronous]
|
||||
→ sendMessagesB_ → withConnLocks({..X..}) [1708]
|
||||
[BLOCKED — connLock(X) held]
|
||||
```
|
||||
|
||||
**Scenario 2**: Async command worker during message ACK with notification
|
||||
|
||||
```
|
||||
Async cmd worker: agentSubscriber (sole subQ reader):
|
||||
tryWithLock "ICAck" [1930→1824] readTBQueue subQ → processAgentMessageConn
|
||||
→ withConnLock(X) → sendPendingGroupMessages
|
||||
→ ack → ackQueueMessage [1899] → sendMessagesB_ → withConnLocks({..X..})
|
||||
→ sendMsgNtf [2381] [BLOCKED — connLock(X) held]
|
||||
→ writeTBQueue subQ [2386]
|
||||
[BLOCKED — subQ full]
|
||||
```
|
||||
|
||||
**Scenario 3**: Synchronous `ackMessage'` API (same mechanism as Scenario 2 but from external API caller)
|
||||
|
||||
```
|
||||
ackMessage' caller: agentSubscriber (sole subQ reader):
|
||||
withConnLock(X) [2254] → sendMessagesB_ → withConnLocks({..X..})
|
||||
→ ack → ackQueueMessage [2267] [BLOCKED — connLock(X) held]
|
||||
→ sendMsgNtf [2381]
|
||||
→ writeTBQueue subQ [2386]
|
||||
[BLOCKED — subQ full]
|
||||
```
|
||||
|
||||
### ConnId overlap verified
|
||||
|
||||
No guard prevents a connection undergoing queue rotation (AM_QTEST_) or ACK processing from being included in `sendMessagesB_`'s batch. During these operations, the connection has `connStatus == ConnReady`, passing all filters in `memberSendAction`.
|
||||
|
||||
### Cascade amplification
|
||||
|
||||
Once any single deadlock triggers, `subQ` never drains. ALL other threads that attempt `writeTBQueue subQ` block progressively — their locks are held forever too. The entire threading system freezes within seconds.
|
||||
|
||||
### Affected code sites (blocking `writeTBQueue subQ` inside `connLock`)
|
||||
|
||||
| Site | File | Lock line | Write line | Events written |
|
||||
|------|------|-----------|------------|----------------|
|
||||
| `runSmpQueueMsgDelivery::notify` | Agent.hs | 2187 | 2238 | SWITCH SPCompleted, ERR INTERNAL |
|
||||
| `runSmpQueueMsgDelivery::internalErr/notifyDel` | Agent.hs | 2187 | 2238 (via notifyDel→notify) | ERR INTERNAL + delMsg |
|
||||
| `ackQueueMessage::sendMsgNtf` | Agent.hs | 2254 or 1930 | 2386 | MSGNTF |
|
||||
|
||||
### Safe patterns that already exist in the codebase
|
||||
|
||||
1. **`isFullTBQueue` + pending TVar** (used at `runCommandProcessing` lines 1782-1784/1937, and `runProcessSMP` lines 3027-3029/3216):
|
||||
```haskell
|
||||
-- Before processing (e.g. line 1782):
|
||||
pending <- newTVarIO []
|
||||
-- During processing — safe notify (e.g. line 1937):
|
||||
notify cmd =
|
||||
let t = (corrId, connId, AEvt (sAEntity @e) cmd)
|
||||
in atomically $ ifM (isFullTBQueue subQ) (modifyTVar' pendingCmds (t :)) (writeTBQueue subQ t)
|
||||
-- After processing — flush (e.g. line 1784):
|
||||
mapM_ (atomically . writeTBQueue subQ) . reverse =<< readTVarIO pending
|
||||
```
|
||||
|
||||
2. **`nonBlockingWriteTBQueue`** (used at Client.hs:789, NtfSubSupervisor.hs:507):
|
||||
```haskell
|
||||
nonBlockingWriteTBQueue q x = do
|
||||
sent <- atomically $ tryWriteTBQueue q x
|
||||
unless sent $ void $ forkIO $ atomically $ writeTBQueue q x
|
||||
```
|
||||
Note: `nonBlockingWriteTBQueue` does NOT preserve ordering — the spawned background thread may complete out of order relative to subsequent direct writes from the same calling thread.
|
||||
|
||||
### Exhaustive proof: no other deadlock scenarios exist
|
||||
|
||||
All 15 `withConnLock` sites in Agent.hs were analyzed. Only 3 write to `subQ`:
|
||||
|
||||
| withConnLock site | Writes subQ? | Safe? |
|
||||
|-------------------|-------------|-------|
|
||||
| switchConnectionAsync' (899) | No | ✓ |
|
||||
| setConnShortLinkAsync' (995) | No | ✓ |
|
||||
| setConnShortLink' (1031) | No | ✓ |
|
||||
| deleteConnShortLink' (1075) | No | ✓ |
|
||||
| allowConnection' (1407) | No | ✓ |
|
||||
| acceptContact' (1417) | No | ✓ |
|
||||
| sendMessagesB_ (1708, `withConnLocks`) | No | ✓ |
|
||||
| tryWithLock/runSmpCommand (1930) | Yes (1937) | ✓ — `isFullTBQueue` check |
|
||||
| tryMoveableWithLock/runSmpCommand (1931) | Yes (1937) | ✓ — `isFullTBQueue` check |
|
||||
| **runSmpQueueMsgDelivery AM_QTEST_ (2187)** | **Yes (2238)** | **✗ — DEADLOCK** |
|
||||
| **ackMessage' (2254)** | **Yes (2386)** | **✗ — DEADLOCK** |
|
||||
| switchConnection' (2298) | No | ✓ |
|
||||
| abortConnectionSwitch' (2328) | No | ✓ |
|
||||
| synchronizeRatchet' (2351) | No | ✓ |
|
||||
| suspendConnection' (2390) | No | ✓ |
|
||||
| **processSMP (3037)** | Yes (3216) | ✓ — `isFullTBQueue` check |
|
||||
|
||||
Note: `processSMP` (line 3037) holds `connLock` and its local `notify` (line 3216) writes to `subQ`, but it uses the safe `isFullTBQueue` pattern. Its `ack` (line 3196) uses `enqueueCmd` (DB-only), NOT `ackQueueMessage`. The actual `ackQueueMessage` runs later from the async command worker via ICAck/ICAckDel.
|
||||
|
||||
Other lock pairs checked — no circular dependencies:
|
||||
- `connLock × DB MVar`: DB never acquires connLock
|
||||
- `entityLock × connLock`: consistent ordering (entity first in chat, conn in agent)
|
||||
- `connLock(X) × connLock(Y)`: single agentSubscriber thread, one `withConnLocks` at a time
|
||||
|
||||
---
|
||||
|
||||
## Deadlock call graph: agentSubscriber → connLock
|
||||
|
||||
All deadlock paths require `agentSubscriber` to synchronously acquire `connLock`. Exhaustive analysis shows that **every such path converges on a single agent function**: `sendMessagesB_` → `withConnLocks` (Agent.hs:1708). No other agent API function called synchronously from the agentSubscriber acquires connLock.
|
||||
|
||||
Verified (FACT): `ackMessageAsync` → `enqueueCommand` only (no connLock). `toggleConnectionNtfs` → no lock. `deleteConnectionAsync` → `deleteLock` not `connLock`. `joinConnectionAsync` → `withInvLock` not `connLock`.
|
||||
|
||||
Also verified (FACT): `Lock = TMVar Text` (Lock.hs:24) is **non-reentrant** — double acquisition on the same thread deadlocks.
|
||||
|
||||
### All 22 trigger paths
|
||||
|
||||
Every path goes through `deliverMessage`/`deliverMessages`/`deliverMessagesB` → `withAgent sendMessagesB` → `sendMessagesB_` → `withConnLocks`:
|
||||
|
||||
| # | Trigger | Chat function | ConnIds locked | Risk |
|
||||
|---|---------|--------------|----------------|------|
|
||||
| 1 | Group CON (Invitee) | `introduceToAll` → broadcast XGrpMemNew | **ALL member connIds** | **HIGHEST** |
|
||||
| 2 | Group MSG XGrpLinkAcpt | `introduceToRemaining` → broadcast | **ALL member connIds** | **HIGHEST** |
|
||||
| 3 | Group CON (Invitee) | `sendIntroductions` → batch intros to new member | new member connId | Medium |
|
||||
| 4 | Group CON (Invitee) | `sendHistory` → batch to new member | new member connId | Medium |
|
||||
| 5 | Group CON | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 6 | Group SENT | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 7 | Group QCONT | `sendPendingGroupMessages` | member connId | Medium |
|
||||
| 8 | Group CON (PendingReview) | `introduceToModerators` → to moderators | moderator connIds | Medium |
|
||||
| 9 | Group CON (PreMember) | `sendXGrpMemCon` → to host | host connId | Low |
|
||||
| 10 | Group CON (PreMember) | `probeMatchingMemberContact` → probes + hashes | member + N matching connIds | Medium |
|
||||
| 11 | Direct CON | `probeMatchingMembers` → probes + hashes | contact + N matching connIds | Medium |
|
||||
| 12 | Direct JOINED | `sendAutoReply` | contact connId | Low |
|
||||
| 13 | Group JOINED | `sendGroupAutoReply` | member connId | Low |
|
||||
| 14 | Group INV | `sendXGrpMemInv` → to host | host connId | Low |
|
||||
| 15 | Group INV (legacy) | `sendGrpInvitation` → to contact | contact connId | Low |
|
||||
| 16 | Group MSG XGrpMemInv | `xGrpMemInv` → `sendGroupMemberMessage` | re-member connId | Low |
|
||||
| 17 | Group MSG XGrpMemDel | `forwardToMember` | deleted member connId | Low |
|
||||
| 18 | Group MSG XGrpLinkMem | `probeMatchingMemberContact` | member + N matching connIds | Medium |
|
||||
| 19 | Group MSG (dup relay) | `saveGroupRcvMsg` error → `sendDirectMemberMessage` | forwarder connId | Low |
|
||||
| 20 | SFDONE | `sendFileDescriptions` → to recipients | recipient connIds | Medium |
|
||||
| 21 | Group MSG XGrpLinkAcpt | `sendHistory` → to accepted member | accepted member connId | Medium |
|
||||
| 22 | Direct MSG (autoAccept) | `autoAcceptFile` → inline accept reply | contact connId | Low (test-only config) |
|
||||
|
||||
### Key observations
|
||||
|
||||
1. **Single bottleneck**: All 22 paths converge on `sendMessagesB_` → `withConnLocks` (Agent.hs:1708). The deadlock is between this lock acquisition and any worker thread holding `connLock` + blocking on `writeTBQueue subQ`.
|
||||
|
||||
2. **Highest-risk paths** (#1, #2): Broadcasting to ALL group members in `introduceToAll` / `introduceToRemaining` acquires `withConnLocks` on ALL member connIds in a single batch. For large groups, this holds the agentSubscriber thread for a long time, during which subQ fills, which causes worker threads holding connLock on any of those connIds to deadlock.
|
||||
|
||||
3. **Medium-risk paths** (#5-7): `sendPendingGroupMessages` fires on every CON/SENT/QCONT. These are frequent and lock the member's connId, which is the SAME connId that a delivery worker or ACK worker may hold while writing to subQ.
|
||||
|
||||
---
|
||||
|
||||
## Analysis: `withConnLocks` in `sendMessagesB_`
|
||||
|
||||
### FACT: the lock protects ratchet encryption state
|
||||
|
||||
`sendMessagesB_` (Agent.hs:1708-1713) acquires `withConnLocks` and executes:
|
||||
|
||||
1. **`getConn_`** — reads connection metadata, send queues from DB
|
||||
2. **`setConnPQSupport`** — updates PQ encryption flag per connection
|
||||
3. **`enqueueMessagesB`** → `enqueueMessageB` → `storeSentMsg_` which calls:
|
||||
- **`updateSndIds`** (AgentStore.hs:899) — increments `internalSndId` (sequential send counter)
|
||||
- **`agentRatchetEncryptHeader`** (Agent.hs:3698) — reads current ratchet via `getRatchetForUpdate`, encrypts message header via `rcEncryptHeader`, writes advanced ratchet state via `updateRatchet`
|
||||
- **`createSndMsg`** + **`createSndMsgDelivery`** — inserts message and delivery records
|
||||
|
||||
All operations run within `unsafeWithStore` → `withTransaction` (single DB transaction per batch).
|
||||
|
||||
### FACT: the lock CANNOT be removed
|
||||
|
||||
Without `withConnLocks`, concurrent `sendMessagesB_` calls targeting the same connection would:
|
||||
- Read the same ratchet state, both encrypt, one overwrite the other → **ratchet desync** (unrecoverable)
|
||||
- Get duplicate `internalSndId` values → **message ID collision**
|
||||
- Race on `setConnPQSupport` → **PQ state inconsistency**
|
||||
|
||||
The lock serializes ALL operations on the connection's encryption state. Removing it would introduce data corruption.
|
||||
|
||||
Note: `sendMessage` (singular, line 530) uses the same `sendMessagesB_` function — there is no lock-free send path.
|
||||
|
||||
### Eliminated strategies
|
||||
|
||||
- **Strategy C (remove lock)**: The lock protects ratchet encryption. Removing it causes unrecoverable ratchet desync. Eliminated.
|
||||
- **Strategy A (async dispatch)**: All 22 chat-layer callers use `deliverMessagesB` return values (delivery IDs, PQ state) synchronously. `forkIO` loses results. Eliminated.
|
||||
- **Strategy W (isFullTBQueue + pending TVar)**: The existing pattern (lines 1937, 3216) buffers events in a local TVar and flushes after lock release. Between lock release and flush, another thread can acquire the same connLock and write events to subQ — reordering events within the same connection. This trades a visible deadlock for invisible ordering bugs. Eliminated.
|
||||
- **Strategy O (per-connection overflow queues)**: Bounded overflow queues with "drop when full" were analyzed. Drop consequences are unacceptable at 5 of 6 write sites — CONF, INFO, CON cause permanent connection failure after ACK; INV loses connection invitations; SENT/MERR leave messages stuck forever. Unbounded overflow defeats backpressure. Eliminated.
|
||||
|
||||
---
|
||||
|
||||
## Solution: move subQ writes outside connLock
|
||||
|
||||
### Root cause
|
||||
|
||||
The `writeTBQueue subQ` calls at the 3 deadlock sites are inside `connLock` by accident of code structure, not necessity. `connLock` protects ratchet encryption state and DB consistency. The `notify` calls write informational events to `subQ` — they do not modify any state that `connLock` protects.
|
||||
|
||||
Moving the writes outside the lock scope eliminates the deadlock: blocking `writeTBQueue subQ` without holding `connLock` is safe — agentSubscriber is free to acquire the lock, process events, and drain `subQ`.
|
||||
|
||||
### Why reordering doesn't matter at these sites
|
||||
|
||||
The chat layer handlers for the 3 deadlock site events do NOT advance the ratchet:
|
||||
|
||||
| Event | Chat handler | Calls sendMessagesB_? |
|
||||
|-------|-------------|----------------------|
|
||||
| SWITCH SPCompleted | Creates internal chat item, updates UI | **No** |
|
||||
| ERR INTERNAL | Logs error to view | **No** |
|
||||
| MSGNTF | `toView CEvtNtfMessage` → empty output | **No** |
|
||||
|
||||
Events that DO trigger ratchet advances (CON, SENT, QCONT → `sendPendingGroupMessages` → `sendMessagesB_`) are all already written OUTSIDE `connLock` in the current code.
|
||||
|
||||
Ratchet state lives in the DB, not in subQ events. agentSubscriber processes events sequentially regardless of arrival order. The SENT-before-SWITCH race already exists in the current code (new queue worker writes SENT outside connLock while old queue worker writes SWITCH inside connLock).
|
||||
|
||||
### Fix: Site 1 — `runSmpQueueMsgDelivery` AM_QTEST_ (line 2187)
|
||||
|
||||
Restructure `withConnLock` to return the event, write outside.
|
||||
|
||||
**Current code** (Agent.hs:2187-2214):
|
||||
```haskell
|
||||
AM_QTEST_ -> withConnLock c connId "runSmpQueueMsgDelivery AM_QTEST_" $ do
|
||||
withStore' c $ \db -> setSndQueueStatus db sq Active
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection cData' rqs sqs -> do
|
||||
let addr = qAddress sq
|
||||
case findQ addr sqs of
|
||||
Just SndQueue {dbReplaceQueueId = Just replacedId, primary} ->
|
||||
case removeQP (\sq' -> dbQId sq' == replacedId && not (sameQueue addr sq')) sqs of
|
||||
Nothing -> internalErr msgId "sent QTEST: queue not found in connection"
|
||||
Just (sq', sq'' : sqs') -> do
|
||||
checkSQSwchStatus sq' SSSendingQTEST
|
||||
atomically $ TM.delete (qAddress sq') $ smpDeliveryWorkers c
|
||||
withStore' c $ \db -> do
|
||||
when primary $ setSndQueuePrimary db connId sq
|
||||
deletePendingMsgs db connId sq'
|
||||
deleteConnSndQueue db connId sq'
|
||||
let sqs'' = sq'' :| sqs'
|
||||
conn' = DuplexConnection cData' rqs sqs''
|
||||
cStats <- connectionStats c conn'
|
||||
notify $ SWITCH QDSnd SPCompleted cStats -- DEADLOCK
|
||||
_ -> internalErr msgId "sent QTEST: ..." -- DEADLOCK (via notifyDel → notify)
|
||||
_ -> internalErr msgId "sent QTEST: ..." -- DEADLOCK
|
||||
_ -> internalErr msgId "QTEST sent not in duplex ..." -- DEADLOCK
|
||||
```
|
||||
|
||||
**New code:**
|
||||
```haskell
|
||||
AM_QTEST_ -> do
|
||||
evt_ <- withConnLock c connId "runSmpQueueMsgDelivery AM_QTEST_" $ do
|
||||
withStore' c $ \db -> setSndQueueStatus db sq Active
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection cData' rqs sqs -> do
|
||||
let addr = qAddress sq
|
||||
case findQ addr sqs of
|
||||
Just SndQueue {dbReplaceQueueId = Just replacedId, primary} ->
|
||||
case removeQP (\sq' -> dbQId sq' == replacedId && not (sameQueue addr sq')) sqs of
|
||||
Nothing -> pure $ Left "sent QTEST: queue not found in connection"
|
||||
Just (sq', sq'' : sqs') -> do
|
||||
checkSQSwchStatus sq' SSSendingQTEST
|
||||
atomically $ TM.delete (qAddress sq') $ smpDeliveryWorkers c
|
||||
withStore' c $ \db -> do
|
||||
when primary $ setSndQueuePrimary db connId sq
|
||||
deletePendingMsgs db connId sq'
|
||||
deleteConnSndQueue db connId sq'
|
||||
let sqs'' = sq'' :| sqs'
|
||||
conn' = DuplexConnection cData' rqs sqs''
|
||||
cStats <- connectionStats c conn'
|
||||
pure $ Right $ SWITCH QDSnd SPCompleted cStats
|
||||
_ -> pure $ Left "sent QTEST: there is only one queue in connection"
|
||||
_ -> pure $ Left "sent QTEST: queue not in connection or not replacing another queue"
|
||||
_ -> pure $ Left "QTEST sent not in duplex connection"
|
||||
-- subQ write is now OUTSIDE connLock — blocking writeTBQueue is safe
|
||||
case evt_ of
|
||||
Right evt -> notify evt
|
||||
Left err -> internalErr msgId err
|
||||
```
|
||||
|
||||
All DB operations remain inside the lock. Only `notify`/`internalErr` (which write to subQ) move outside. `internalErr` calls `notifyDel` = `notify >> delMsg` — both `notify` (subQ write) and `delMsg` (`deleteSndMsgDelivery`, keyed on unique msgId) are safe outside the lock. The existing double-delete pattern (`delMsg` inside `internalErr` + `delMsgKeep` at line 2216) is preserved.
|
||||
|
||||
### Fix: Sites 2 & 3 — `ackQueueMessage::sendMsgNtf` (line 2386)
|
||||
|
||||
Change `ackQueueMessage` to return the MSGNTF event instead of writing it to subQ. Callers write to subQ after releasing connLock.
|
||||
|
||||
**Current code** (Agent.hs:2371-2386):
|
||||
```haskell
|
||||
ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM ()
|
||||
ackQueueMessage c rq@RcvQueue {userId, connId, server} srvMsgId = do
|
||||
atomically $ incSMPServerStat c userId server ackAttempts
|
||||
tryAllErrors (sendAck c rq srvMsgId) >>= \case
|
||||
Right _ -> sendMsgNtf ackMsgs
|
||||
Left (SMP _ SMP.NO_MSG) -> sendMsgNtf ackNoMsgErrs
|
||||
Left e -> ...
|
||||
where
|
||||
sendMsgNtf stat = do
|
||||
atomically $ incSMPServerStat c userId server stat
|
||||
whenM (liftIO $ hasGetLock c rq) $ do
|
||||
atomically $ releaseGetLock c rq
|
||||
brokerTs_ <- eitherToMaybe <$> tryAllErrors (withStore c $ \db -> getRcvMsgBrokerTs db connId srvMsgId)
|
||||
atomically $ writeTBQueue (subQ c) ("", connId, AEvt SAEConn $ MSGNTF srvMsgId brokerTs_)
|
||||
```
|
||||
|
||||
**New code** — return `Maybe ATransmission` instead of writing:
|
||||
```haskell
|
||||
ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM (Maybe ATransmission)
|
||||
ackQueueMessage c rq@RcvQueue {userId, connId, server} srvMsgId = do
|
||||
atomically $ incSMPServerStat c userId server ackAttempts
|
||||
tryAllErrors (sendAck c rq srvMsgId) >>= \case
|
||||
Right _ -> sendMsgNtf ackMsgs
|
||||
Left (SMP _ SMP.NO_MSG) -> sendMsgNtf ackNoMsgErrs
|
||||
Left e -> ... >> pure Nothing
|
||||
where
|
||||
sendMsgNtf stat = do
|
||||
atomically $ incSMPServerStat c userId server stat
|
||||
ifM (liftIO $ hasGetLock c rq)
|
||||
(do atomically $ releaseGetLock c rq
|
||||
brokerTs_ <- eitherToMaybe <$> tryAllErrors (withStore c $ \db -> getRcvMsgBrokerTs db connId srvMsgId)
|
||||
pure $ Just ("", connId, AEvt SAEConn $ MSGNTF srvMsgId brokerTs_))
|
||||
(pure Nothing)
|
||||
```
|
||||
|
||||
**Caller 1: `ackMessage'`** (Agent.hs:2253-2267) — return event from `withConnLock`, write after:
|
||||
```haskell
|
||||
ackMessage' c connId msgId rcptInfo_ = do
|
||||
t_ <- withConnLock c connId "ackMessage" $ do
|
||||
SomeConn _ conn <- withStore c (`getConn` connId)
|
||||
case conn of
|
||||
DuplexConnection {} -> do
|
||||
t_ <- ack
|
||||
sendRcpt conn
|
||||
del
|
||||
pure t_
|
||||
RcvConnection {} -> do
|
||||
t_ <- ack
|
||||
del
|
||||
pure t_
|
||||
SndConnection {} -> throwE $ CONN SIMPLEX "ackMessage"
|
||||
ContactConnection {} -> throwE $ CMD PROHIBITED "ackMessage: ContactConnection"
|
||||
NewConnection _ -> throwE $ CMD PROHIBITED "ackMessage: NewConnection"
|
||||
-- subQ write is OUTSIDE connLock
|
||||
case t_ of
|
||||
Just t -> atomically $ writeTBQueue (subQ c) t
|
||||
Nothing -> pure ()
|
||||
```
|
||||
|
||||
**Caller 2: `ICAck` / `ICAckDel`** (Agent.hs:1823-1824) — inline `tryWithLock` as `tryCommand` + `withConnLock`, write subQ between the two scopes:
|
||||
|
||||
`tryWithLock name = tryCommand . withConnLock c connId name` — by inlining, the subQ write can be placed outside `withConnLock` but inside `tryCommand` (retaining retry/error handling).
|
||||
|
||||
```haskell
|
||||
ICAck rId srvMsgId -> withServer $ \srv ->
|
||||
tryCommand $ do
|
||||
t_ <- withConnLock c connId "ICAck" $ ack srv rId srvMsgId
|
||||
-- subQ write is OUTSIDE connLock — cannot deadlock with agentSubscriber
|
||||
forM_ t_ $ atomically . writeTBQueue subQ
|
||||
|
||||
ICAckDel rId srvMsgId msgId -> withServer $ \srv ->
|
||||
tryCommand $ do
|
||||
t_ <- withConnLock c connId "ICAckDel" $ do
|
||||
t_ <- ack srv rId srvMsgId
|
||||
withStore' c (\db -> deleteMsg db connId msgId)
|
||||
pure t_
|
||||
-- subQ write is OUTSIDE connLock — cannot deadlock with agentSubscriber
|
||||
forM_ t_ $ atomically . writeTBQueue subQ
|
||||
```
|
||||
|
||||
Where `ack` now returns `AM (Maybe ATransmission)`:
|
||||
```haskell
|
||||
ack srv rId srvMsgId = do
|
||||
rq <- withStore c $ \db -> getRcvQueue db connId srv rId
|
||||
ackQueueMessage c rq srvMsgId
|
||||
```
|
||||
|
||||
All subQ writes for MSGNTF are now outside connLock. FIFO ordering is preserved — no `nonBlockingWriteTBQueue`, no forked threads. The same thread that held the lock writes to subQ sequentially after releasing it.
|
||||
|
||||
### Race analysis
|
||||
|
||||
Window between connLock release and subQ write at Site 1:
|
||||
|
||||
| Thread | Can acquire connLock(X)? | Writes subQ? | Consequence |
|
||||
|--------|-------------------------|-------------|-------------|
|
||||
| agentSubscriber via sendMessagesB_ | Yes | **No** (encrypts only) | No race |
|
||||
| processSMP for connId X | Yes | Yes (pending flush) | MSG before SWITCH — cosmetic |
|
||||
| runCommandProcessing for connId X | Yes | Yes (pending flush) | Command response before SWITCH — cosmetic |
|
||||
| New queue delivery worker | No (SENT outside lock) | Yes | SENT before SWITCH — cosmetic, **already exists in current code** |
|
||||
|
||||
All races are cosmetic UI ordering. None affect ratchet state, protocol correctness, or message delivery.
|
||||
|
||||
### Summary of changes
|
||||
|
||||
| File | Change | Lines affected |
|
||||
|------|--------|---------------|
|
||||
| Agent.hs | Restructure AM_QTEST_ to return event from `withConnLock`, write outside | ~2187-2214 |
|
||||
| Agent.hs | Change `ackQueueMessage` return type to `AM (Maybe ATransmission)`, return event instead of writing | ~2371-2386 |
|
||||
| Agent.hs | `ackMessage'`: return event from `withConnLock`, write outside | ~2253-2267 |
|
||||
| Agent.hs | `ICAck`/`ICAckDel`: inline `tryCommand` + `withConnLock`, write subQ between scopes | ~1823-1824 |
|
||||
| Agent.hs | `ack` helper: propagate new return type | ~1899-1901 |
|
||||
|
||||
No new data structures. No new modules. No changes to other write sites (1937, 3216 — already safe). ~25 lines changed total.
|
||||
@@ -0,0 +1,150 @@
|
||||
# SimpleX Network Protocol Specifications — Governance and Evolution (draft)
|
||||
|
||||
## Why this document exists
|
||||
|
||||
SimpleX Network protocol specifications must evolve as the network grows. This document defines how specifications change, who governs those changes, and how the history of changes is preserved.
|
||||
|
||||
### Lessons from the web: why ratcheted governance matters
|
||||
|
||||
The web's governance history demonstrates both the necessity of consortium governance and the dangers of getting the transition wrong.
|
||||
|
||||
[Tim Berners-Lee invented the web in 1991](https://home.cern/science/computing/birth-web/short-history-web). [Netscape took over in 1994](https://en.wikipedia.org/wiki/Netscape_Navigator), driving rapid innovation as a single company — SSL, cookies, JavaScript, and the features that made the web commercially viable. In 1994, [W3C was founded](https://www.w3.org/about/history/) as a consortium hosted across multiple independent institutions (MIT in the US, INRIA/ERCIM in Europe, Keio University in Japan, later Beihang University in China) to govern web standards.
|
||||
|
||||
The transition from company-led innovation to consortium governance was abrupt rather than gradual. Netscape's decline (accelerated by the [browser wars](https://en.wikipedia.org/wiki/Browser_wars) and [AOL acquisition](https://cybercultural.com/p/1999-the-fall-of-netscape-and-the-rise-of-mozilla/)) transferred control to a standards body that prioritized process over progress. The result was [a lost decade of web stagnation](https://eev.ee/blog/2020/02/01/old-css-new-css/): CSS 2.0 shipped in 1998; CSS 2.1 didn't reach Candidate Recommendation until 2004 and wasn't finalized until 2011. W3C pursued XHTML and rejected proposed enhancements to HTML, until frustrated engineers from Apple, Mozilla, and Opera formed [WHATWG in 2004](https://en.wikipedia.org/wiki/WHATWG) to build HTML5 outside W3C's process. The abrupt governance transition, without a mechanism to balance community guarantees against the imperative to continue evolving the product at pace, dramatically slowed web evolution at the time it was needed most.
|
||||
|
||||
Then in 2023, [W3C restructured from a multi-host consortium into a single 501(c)(3) nonprofit entity](https://www.w3.org/press-releases/2023/w3c-le-launched/) — W3C Inc, incorporated in the US. The previous structure distributed governance across four independent university hosts in different countries, making capture by any single entity structurally difficult. The new structure concentrates governance in a single legal entity with a board of directors. While presented as modernization, this effectively ended the decentralized consortium model that had protected web standards for nearly three decades.
|
||||
|
||||
### The governance double ratchet
|
||||
|
||||
SimpleX follows the same Netscape-to-consortium evolution path, but with two ratchets designed to prevent both failure modes — stagnation from premature governance transfer, and capture from governance centralization:
|
||||
|
||||
- **Licensing ratchet**: all contributed IP is licensed under AGPLv3 (software) and Creative Commons (documentation), perpetually and irrevocably. What is licensed cannot be unlicensed. If a Party transfers Licensed IP, the licensing obligations transfer with it.
|
||||
|
||||
- **Governance ratchet**: power can be given to the SimpleX Network Consortium, but never taken back. The Consortium Agreement requires majority decision of all Governing Parties for changes to the agreement itself, IP policy, and admission or removal of parties.
|
||||
|
||||
The ratcheted transition is historically proven to be necessary. It allows the company to continue driving rapid product innovation (as Netscape did for the web) while incrementally and irreversibly transferring governance to the consortium, without the abrupt handover that stalled web evolution or the centralization that later undermined it.
|
||||
|
||||
### Specification governance via the Consortium Agreement
|
||||
|
||||
The SimpleX Network Consortium Agreement (being deployed in 2026) establishes two levels of intellectual property governance: **Licensed IP** (all contributed protocol specifications, software, and documentation, licensed perpetually and irrevocably) and **Core IP** (the subset essential to the network, requiring consortium governance to change). The distinction between these levels and how they map to the RFC process is described in [Standard vs Core specifications](#standard-vs-core-specifications) below.
|
||||
|
||||
## Specification change process: protocol specifications and RFCs
|
||||
|
||||
Protocol knowledge lives in two places:
|
||||
|
||||
### `protocol/` — Consolidated specifications
|
||||
|
||||
Each file is a complete, self-contained description of a protocol as it exists today. Like consolidated legislation in the UK legal system: the full current law in one document, not a patchwork of amendments.
|
||||
|
||||
Consolidated specifications are maintained on every code change that affects protocol behavior. With LLMs, the cost of maintaining consolidated documents collapses — reworking prose to incorporate a new RFC is now inexpensive relative to the value of a single authoritative document per protocol.
|
||||
|
||||
Implementers read `protocol/`. They should never need to reconstruct current behavior from a base spec plus a chain of RFCs.
|
||||
|
||||
### `rfcs/` — Protocol evolution commits
|
||||
|
||||
Each RFC describes a single change to a protocol specification. RFCs are the atomic unit of protocol evolution — analogous to commits in version control, or amending acts in legislation.
|
||||
|
||||
An RFC is not part of the protocol specification. It becomes part of the specification only when embedded into the consolidated `protocol/` document. The RFC itself remains as a permanent historical record of what changed, when, and why.
|
||||
|
||||
## RFC lifecycle
|
||||
|
||||
```
|
||||
┌——> done/ ——> standard/
|
||||
draft (root) ——>──┤
|
||||
└——> rejected/
|
||||
```
|
||||
|
||||
### Draft — `rfcs/*.md`
|
||||
|
||||
A proposal for a protocol change. Not yet implemented. Active proposals live in the `rfcs/` root directory.
|
||||
|
||||
Named by proposal date: `YYYY-MM-DD-topic.md`.
|
||||
|
||||
A draft may be rejected if the proposal is considered but not accepted for implementation.
|
||||
|
||||
### Done — `rfcs/done/`
|
||||
|
||||
Implemented in code. The protocol change described by this RFC exists in the codebase, but the RFC has not yet been verified against the actual implementation (code may have diverged from the proposal during implementation).
|
||||
|
||||
### Standard — `rfcs/standard/`
|
||||
|
||||
Verified against the actual implementation and synchronized with code. The RFC accurately describes what was implemented. This is a permanent historical record — standard RFCs are never modified or removed.
|
||||
|
||||
On promotion to standard, the RFC is:
|
||||
1. Renamed from proposal date to standardization date: `YYYY-MM-DD-topic.md` (new date, same topic slug)
|
||||
2. Updated with a document history header capturing the full lifecycle
|
||||
3. Embedded into the corresponding `protocol/` consolidated specification
|
||||
|
||||
The `protocol/` document references embedded RFCs by name (e.g., "Private message routing added by RFC 2023-09-12-second-relays, standardized 2026-XX-XX"), similar to UK legislation citing the amending act for each clause.
|
||||
|
||||
Protocol version numbers make it clear which RFCs are included in which protocol revision — no separate tracking is needed.
|
||||
|
||||
### Rejected — `rfcs/rejected/`
|
||||
|
||||
Draft proposals that were considered but not accepted for implementation. Only drafts move to rejected — once an RFC is implemented (done/), it proceeds to standard/ after verification. Preserved for historical record of design decisions.
|
||||
|
||||
### Document history header
|
||||
|
||||
Every RFC in `standard/` carries a history header:
|
||||
|
||||
```
|
||||
---
|
||||
Proposed: YYYY-MM-DD
|
||||
Implemented: YYYY-MM-DD
|
||||
Standardized: YYYY-MM-DD
|
||||
Protocol: simplex-messaging v9 (or whichever protocol this amends)
|
||||
---
|
||||
```
|
||||
|
||||
## Governance
|
||||
|
||||
SimpleX Network follows the Netscape-to-W3C evolution path, with ratcheted rather than abrupt transitions:
|
||||
|
||||
| Phase | Period | Governance | Development process |
|
||||
|-------|--------|-----------|-------------------|
|
||||
| Protocol invented | 2020 | Two people | Prototype developed |
|
||||
| SimpleX Chat Ltd | 2022 | One company | Product-first: code leads, specs follow |
|
||||
| SimpleX Network Consortium | 2026 | Agreement of SimpleX Chat Ltd and non-profit entities | Product-first for standard; standards-first for core |
|
||||
| Decentralized governance | Future | TBD (DAO research ongoing) | Standards-first |
|
||||
|
||||
### Current: product-first development
|
||||
|
||||
SimpleX protocols currently follow a product-first development process: requirements drive code, code drives specification. RFCs are written as design proposals before implementation, but implementation details are figured out in code. Consolidated protocol specifications in `protocol/` are then amended to match the implementation.
|
||||
|
||||
This process is governed by SimpleX Chat Ltd as the IP Holding Party under the Consortium Agreement.
|
||||
|
||||
Any Specification Author (as defined in the Consortium Agreement) may propose RFCs. Acceptance and standardization decisions are made by SimpleX Chat Ltd during the current product-first phase.
|
||||
|
||||
### Standard vs Core specifications
|
||||
|
||||
The distinction between standard and core maps directly to the two levels of IP governance in the Consortium Agreement, and reflects the difference between product-first and standards-first development:
|
||||
|
||||
**Standard** — Licensed IP, not yet under consortium governance. Governed by the company.
|
||||
|
||||
All contributed protocol specifications are Licensed IP under the Consortium Agreement. Standard specifications follow product-first development: the company can evolve them with product needs, and they must be maintained on every code change that affects protocol behavior.
|
||||
|
||||
Standard specifications live in `rfcs/standard/` and `protocol/`.
|
||||
|
||||
**Core** — Governed IP, governed by the consortium.
|
||||
|
||||
A subset of standard specifications will be designated as Core IP under the Consortium Agreement. Core specifications will follow standards-first development: specification changes must be agreed via Governing Decision before code changes.
|
||||
|
||||
This is a legally binding commitment. Once Licensed IP is included in Core IP, the company that owns the code cannot unilaterally change it — even though they own the code, the Consortium Agreement requires a Governing Decision for any change to Core IP. This protects the fundamental properties of the network (privacy, security, decentralization) from unilateral modification by any single party.
|
||||
|
||||
The designation of specific specifications as Core IP is itself a Governing Decision that requires Consortium vote. The transition will happen incrementally as protocols stabilize — the governance ratchet ensures that each designation is irreversible.
|
||||
|
||||
The exact mechanism for distinguishing core from standard within the RFC and protocol folder structure is TBD — it will be decided as the first protocols are designated as Core IP.
|
||||
|
||||
### Future: standards-first development
|
||||
|
||||
As more protocols are designated as Core IP, development naturally transitions to a standards-first process for a growing portion of the protocol suite. The governance ratchet ensures this transition is gradual and irreversible — each protocol that becomes core gains the protection of consortium governance permanently, while remaining standard protocols continue to evolve at product pace.
|
||||
|
||||
## Current state
|
||||
|
||||
| Location | Contents | Count |
|
||||
|----------|----------|-------|
|
||||
| `protocol/` | Consolidated specs (SMP v9, Agent v5, XFTP v2, XRCP v1, Push v2, PQDR v1) | 6 specs + overview |
|
||||
| `rfcs/` root | Active draft proposals | 19 |
|
||||
| `rfcs/done/` | Implemented, not yet verified | 25 |
|
||||
| `rfcs/standard/` | Verified against implementation | (to be populated) |
|
||||
| `rfcs/rejected/` | Draft proposals not accepted | 7 |
|
||||
@@ -196,6 +196,56 @@ dhPublic = length x509encoded
|
||||
|
||||
The above assumes that the client can only send one message to an SMP relay and then has to wait for response before sending the next message. Missing the response would cause re-delivery (further improvement is possible when proxy detects these redelieveries and not send them to relays but simply reply with the same response).
|
||||
|
||||
### Implementation considerations for the client
|
||||
|
||||
While client/server protocol is rather straightforward to implement, and it is already working, there are some decisions to make about how the client makes decisions about.
|
||||
|
||||
1. When to use proxy and when to connect directly to the destination relay.
|
||||
|
||||
While from the perspective of threat model improvement it may be beneficial to always use the proxy, choosing the proxy that is different from other relays in the connection, initially we need to make it opt-in, with an option to only use it for unknown destination relays, to minimize any unexpected adverse effect on the delivery latency.
|
||||
|
||||
Proxy mode will be passed from the client via NetworkConfig.
|
||||
|
||||
2. Which proxying relays to use.
|
||||
|
||||
Ability to request access to the session with the destination relay (and to create such session) is protected with the same basic auth approach as creating queues - the logic here is that opening private servers to all users as proxies would increase the scenarios for DoS attacks (which is the case with the public servers).
|
||||
|
||||
The open question is whether the client should choose proxies from:
|
||||
- all configured relays.
|
||||
- there should be a subset of configured relays.
|
||||
- there should be a separate list.
|
||||
|
||||
E.g., there could be a second toggle in the relay configuration to allow using relay as proxy, in addition to the current toggle that allows creating queues.
|
||||
|
||||
For simplicity, initially we will just use all enabled relays as potential proxies.
|
||||
|
||||
3. How many proxying relays should be used during one session.
|
||||
|
||||
This is not a simple question, and it creates a contradiction between two risks:
|
||||
- collusion between proxies and destination relays simplifies correlating sending clients by session - from the point of view of this risk, clients should follow the same policy for creating connections with proxies, that is to create a new connection for each user profile, and if transport isolation is set to "per connection" - for each destination queue.
|
||||
- traffic correlation by observable traffic sessions (particularly if an attacker can observe user's ISP traffic or multiple proxies) - from this point of view, it would be beneficial to use fewer proxies and fewer connections with proxies and see the risk of proxy colluding with the destination relay as lower than the risk of traffic observation that in the case of multiple sessions would allow to correlate traffic to rarely used destination relays (any private self-hosted relays) and the traffic of the user to a given proxy, to prove the fact of user communicating with the destination relay via the proxy.
|
||||
|
||||
While we can transfer this choice on the users, it seems a complex decision to make, and overall the second risk (traffic correlation) seems more important to address than the first.
|
||||
|
||||
In any case possible options are:
|
||||
1. Extreme option 1: Create a new proxy session, with the new random proxy, for each potential transport session that would exist if the user were to be connected to destination relays directly. That is, never to mix access to multiple relays from multiple user profiles (and in case of per-connection isolation, to multiple queues) into a one client session with proxy. This is a rather radical option that nullifies any advantages of having fewer sessions with proxies than there would have been with the destination relays and removes any benefits of batching destination server session requests (PRXY comands).
|
||||
2. Extreme option 2: Use only one proxy session at the time, mixing traffic from all user profiles and to all destination servers (and for all queues) into a session with one proxy. This minimizes the risks of traffic correlation in case of non-colluding proxy, but maximises the risk in case it colludes with the destination relays.
|
||||
3. Balanced option: Use one proxy session per user profile, but mix traffic to multiple queues irrespective of connection isolation option and to all destination servers. Given that connection isolation is an experimental option, this makes the most sense, but it would have to be disclosed.
|
||||
4. Less balanced option: take connection isolation option into account and create a new proxy connection for each destination queue. This feels worse than option 3.
|
||||
|
||||
If option 3 is chosen, then the transport session key with the proxy would be different from the transport session key with the relay - proxy session will only use UserId as the key, and the relay session uses (UserId, Server, Maybe EntityId) as the key.
|
||||
|
||||
If option 4 is chosen, the keys would also be different, as the proxy would then use (UserId, Maybe (Server, EntityId)) as the key.
|
||||
|
||||
We could potentially key proxy sessions (and create proxy connections) per each destination relay, in the same way as we key relays themselves, but it seems to have the least sense, as we neither achieve isolation by queue in case proxy and destination relay collude, nor we sufficiently protect from traffic correlation by any observers.
|
||||
|
||||
The implemented design is this:
|
||||
- for each destination relay a random proxy is chosen and used to send all messages - all requests from a client coalesce to a single session.
|
||||
- transport isolation mode is taken into account, that is if per-connection isolation is enabled, then a separate proxy connection will be created for each messaging queue.
|
||||
- supported modes when proxy is used: always, for unknown relays, for unknown relays when IP address is not protected, never.
|
||||
|
||||
This decision is made because the argument for protection against collusion between proxy and relay and more balanced traffic distribution is stronger than the argument for protection against traffic correlation, because even mixing all messages to one proxy connection does not provide protection against traffic correlation by time, so in any case it requires adding delays.
|
||||
|
||||
### Threat model for SMP proxy and changes to threat model for SMP
|
||||
|
||||
#### SMP proxy
|
||||
@@ -53,7 +53,7 @@ The session invitation contains this data:
|
||||
- CA TLS certificate fingerprint of the controller - this is part of long term identity of the controller established during the first session, and repeated in the subsequent session announcements.
|
||||
- Session Ed25519 public key used to verify the announcement and commands - this mitigates the compromise of the long term signature key, as the controller will have to sign each command with this key first.
|
||||
- Long-term Ed25519 public key used to verify the announcement and commands - this is part of the long term controller identity.
|
||||
- Session X25519 DH key and sntrup761 KEM encapsulation key to agree session encryption (both for multicast announcement and for commands and responses in TLS), as described in https://datatracker.ietf.org/doc/draft-josefsson-ntruprime-hybrid/. The new keys are used for each session, and if client key is already available (from the previous session), the computed shared secret will be used to encrypt the announcement multicast packet. The out-of-band invitation is unencrypted. These DH public key and KEM encapsulation key are always sent unencrypted. NaCL Cryptobox is used for encryption.
|
||||
- Session X25519 DH key and sntrup761 KEM encapsulation key to agree session encryption (both for multicast announcement and for commands and responses in TLS), as described in https://datatracker.ietf.org/doc/draft-josefsson-ntruprime-hybrid/. The new keys are used for each session, and if client key is already available (from the previous session), the computed shared secret will be used to encrypt the announcement multicast packet. The out-of-band invitation is unencrypted. This DH public key is always sent unencrypted. NaCL Cryptobox is used for encryption.
|
||||
|
||||
Host device decrypts (except the first session) and validates the invitation:
|
||||
- Session signature is valid.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Faster connection establishment
|
||||
|
||||
## Problem
|
||||
|
||||
SMP protocol is unidirectional, and to create a connection users have to agree two messaging queues.
|
||||
|
||||
V1 of handshake protocol required 5 messages and multiple HELLO sent between the users, which consumed a lot of traffic.
|
||||
|
||||
V2 of handshake protocol was optimized to remove multiple HELLO and also REPLY message, thanks to including queue address together with the key to secure this queue into the confirmation message.
|
||||
|
||||
This eliminated unnecessary traffic from repeated HELLOs, but still requires 4 messages in total and 2 times of each client being online. It is perceived by the users as "it didn't work" (because they see "connecting" after using the link) or "we have to be online at the same time" (and even in this case it is slow on bad network). This hurts usability and creates churn of the new users, as unless people are onboarded by the friends who know how the app works, they cannot figure out how to connect.
|
||||
|
||||
Ideally, we want to have handshake protocol design when an accepting user can send messages straight after using the link (their client says "connected") and the initiating client can send messages as soon as it received confirmation message with the profile.
|
||||
|
||||
This RFC proposes modifications to SMP and SMP Agent protocols to reduce the number of required messages to 2 and allows accepting client to send messages straight after using the link (and sending the confirmation), before receiving the profile of the initiating client in the second message, and the initiating client can send the messages straight after processing the confirmation and sending its own confirmation.
|
||||
|
||||
## Solution
|
||||
|
||||
The current protocol design allows additional confirmation step where the initiating client can confirm the connection having received the profile of the sender. We don't use it in the UI - this confirmation is done automatically and unconditionally.
|
||||
|
||||
Instead of requiring the initiating client to secure its queue with sender's key, we can allow the accepting client to secure it with the additional SKEY command. This would avoid "connecting" state but would introduce "Profile unknown" state where the accepting client does not yet have the profile of the initiating client. In this case we could also use the non-optional alias created during the connection (or have something like "Add alias to be able to send messages immediately" and show warning if the user proceeds without it).
|
||||
|
||||
The additional advantage here is that if the queue of the initiating client was removed, the connection will not procede to create additional queue, failing faster.
|
||||
|
||||
These are the proposed changes:
|
||||
|
||||
1. Modify NEW command to add flag allowing sender to secure the queue (it should not be allowed if queue is created for the contact address).
|
||||
2. Include flag into the invitation link URI and in reply address encoding that queue(s) can be secured by the sender (to avoid coupling with the protocol version and preserve the possibility of the longer handshakes).
|
||||
3. Add SKEY command to SMP protocol to allow the sender securing the message queue.
|
||||
4. This command has to be supported by SMP proxy as well, so that the sender does not connect to the recipient's server directly.
|
||||
5. Accepting client will secure the messaging queue before sending the confirmation to it.
|
||||
6. Initiating client will secure the messaging queue before sending the confirmation.
|
||||
|
||||
See [this sequence diagram](../protocol/diagrams/duplex-messaging/duplex-creating-v6.mmd) for the updated handshake protocol.
|
||||
|
||||
Changes to threat model: the attacker who compromised TLS and knows the queue address can block the connection, as the protocol no longer requires the recipient to decrypt the confirmation to secure the queue.
|
||||
|
||||
Possibly, "fast connection" should be an option in Privacy & security settings.
|
||||
|
||||
## Implementation questions
|
||||
|
||||
Currently we store received confirmations in the database, so that the client can confirm them. This becomes unnecessary.
|
||||
@@ -0,0 +1,67 @@
|
||||
name: SimpleX Chat - smp-server
|
||||
|
||||
services:
|
||||
oneshot:
|
||||
image: ubuntu:latest
|
||||
environment:
|
||||
CADDYCONF: |
|
||||
${CADDY_OPTS:-}
|
||||
|
||||
http://{$$ADDR} {
|
||||
redir https://{$$ADDR}{uri} permanent
|
||||
}
|
||||
|
||||
{$$ADDR}:8443 {
|
||||
tls {
|
||||
key_type rsa4096
|
||||
}
|
||||
}
|
||||
command: sh -c 'if [ ! -f /etc/caddy/Caddyfile ]; then printf "$${CADDYCONF}" > /etc/caddy/Caddyfile; fi'
|
||||
volumes:
|
||||
- ./caddy_conf:/etc/caddy
|
||||
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
depends_on:
|
||||
oneshot:
|
||||
condition: service_completed_successfully
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
ADDR: ${ADDR?"Please specify the domain."}
|
||||
volumes:
|
||||
- ./caddy_conf:/etc/caddy
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
ports:
|
||||
- 80:80
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: "test -d /data/caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR} || exit 1"
|
||||
interval: 1s
|
||||
retries: 60
|
||||
|
||||
smp-server:
|
||||
image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest}
|
||||
depends_on:
|
||||
caddy:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ADDR: ${ADDR?"Please specify the domain."}
|
||||
PASS: ${PASS:-}
|
||||
volumes:
|
||||
- ./smp_configs:/etc/opt/simplex
|
||||
- ./smp_state:/var/opt/simplex
|
||||
- type: volume
|
||||
source: caddy_data
|
||||
target: /certificates
|
||||
volume:
|
||||
subpath: "caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR}"
|
||||
ports:
|
||||
- 443:443
|
||||
- 5223:5223
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||