mirror of
https://github.com/signalapp/Signal-Server.git
synced 2025-12-05 01:10:13 +00:00
Compare commits
1588 Commits
v10.43.0
...
08f6ec639c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f6ec639c | ||
|
|
6c3cfc88b5 | ||
|
|
389d44fd80 | ||
|
|
7604306818 | ||
|
|
92e133b21f | ||
|
|
4af50986e0 | ||
|
|
c72458b47a | ||
|
|
aa2f9e5a65 | ||
|
|
f13837d2f2 | ||
|
|
3ff2af47cb | ||
|
|
c719da3527 | ||
|
|
0ea52b785e | ||
|
|
1ce1c298d3 | ||
|
|
4d5cc4dc22 | ||
|
|
640274108e | ||
|
|
dd17ddc98c | ||
|
|
4c4a954c1c | ||
|
|
65ce9af366 | ||
|
|
4af0de2ab2 | ||
|
|
ccf72a45db | ||
|
|
f3744fbcb1 | ||
|
|
ec08731e6d | ||
|
|
852591df40 | ||
|
|
9ff9b3a7b3 | ||
|
|
dd4e058cd7 | ||
|
|
85226bdd87 | ||
|
|
faa74469ea | ||
|
|
bb94975d74 | ||
|
|
298b0d8d28 | ||
|
|
9da643dd69 | ||
|
|
6dc4bfe5fa | ||
|
|
23a3e32eb8 | ||
|
|
bf6939ec00 | ||
|
|
3b310463c3 | ||
|
|
f7eb6fab33 | ||
|
|
ce945ff245 | ||
|
|
4dbd564442 | ||
|
|
24f8f48a26 | ||
|
|
a2ce37fd53 | ||
|
|
c4d55e099e | ||
|
|
0f950917d8 | ||
|
|
3116913378 | ||
|
|
d7a9e3c9f3 | ||
|
|
14010a4f83 | ||
|
|
ad0bcd5436 | ||
|
|
88d458cf79 | ||
|
|
342c8a1b28 | ||
|
|
ad2500d4fd | ||
|
|
c2ebabad58 | ||
|
|
1105753aab | ||
|
|
9378b9a6e6 | ||
|
|
c68e3103c4 | ||
|
|
c9760f4c38 | ||
|
|
73765fc4ec | ||
|
|
9e1b716548 | ||
|
|
a2f2fc93b0 | ||
|
|
9751569dc7 | ||
|
|
d6c15ef1d5 | ||
|
|
e0eaa76ebf | ||
|
|
0d4d9c0af5 | ||
|
|
4ffd768aac | ||
|
|
b8a720c5b4 | ||
|
|
ab1ec86cd2 | ||
|
|
e9d6b91416 | ||
|
|
63f892add1 | ||
|
|
6e42b2898c | ||
|
|
9384813752 | ||
|
|
70ac4ad139 | ||
|
|
4ab58e950b | ||
|
|
7f301cbf95 | ||
|
|
5a7660d3ae | ||
|
|
850172f6a3 | ||
|
|
4e73162055 | ||
|
|
4127cf90ab | ||
|
|
08cc7bc462 | ||
|
|
2cf2391f38 | ||
|
|
007dde8d45 | ||
|
|
f80e30f9f2 | ||
|
|
ff5201a58e | ||
|
|
8954708d77 | ||
|
|
3cf194e476 | ||
|
|
bad2602491 | ||
|
|
a647c1bfdf | ||
|
|
1bba30a81e | ||
|
|
827d8388d0 | ||
|
|
70efb19a86 | ||
|
|
35ffb208e3 | ||
|
|
e50dcd185d | ||
|
|
1cd446ee31 | ||
|
|
1847c6d4ca | ||
|
|
1770558d5e | ||
|
|
e0d39212ec | ||
|
|
31ebe9071a | ||
|
|
8f65486fab | ||
|
|
7e3540bda0 | ||
|
|
c544628dfe | ||
|
|
61b162d0a1 | ||
|
|
8c2d738924 | ||
|
|
efde8a31f9 | ||
|
|
b2dd315177 | ||
|
|
dc3920a99c | ||
|
|
b8e8fd3313 | ||
|
|
a5423b6e21 | ||
|
|
89b37015c6 | ||
|
|
2af3088571 | ||
|
|
774cc52b61 | ||
|
|
f52a262741 | ||
|
|
cd957e0347 | ||
|
|
8060d74899 | ||
|
|
0e1e248564 | ||
|
|
d33761f107 | ||
|
|
89c7521be0 | ||
|
|
8e429e267f | ||
|
|
8fe87b77e4 | ||
|
|
194e43926a | ||
|
|
4c0281f540 | ||
|
|
75391785f8 | ||
|
|
ebdc5a30f8 | ||
|
|
f57093a94a | ||
|
|
53f9c7b31f | ||
|
|
bc20aee7c9 | ||
|
|
807e03ca2b | ||
|
|
a747afb487 | ||
|
|
eb42d22155 | ||
|
|
8825396fc1 | ||
|
|
f616612104 | ||
|
|
a8c6fa93e0 | ||
|
|
32cf12e9af | ||
|
|
1cc933b6bc | ||
|
|
dbbd913445 | ||
|
|
50e7301dd8 | ||
|
|
552edbc1eb | ||
|
|
7f5ea6608c | ||
|
|
78a7112675 | ||
|
|
be8b44d645 | ||
|
|
7ca3604601 | ||
|
|
c5af8f3a9e | ||
|
|
d67847e5b1 | ||
|
|
3a5704a5cc | ||
|
|
50a54136e8 | ||
|
|
a1d9c4c062 | ||
|
|
4acb3b5ac7 | ||
|
|
b76eaa1098 | ||
|
|
c883cd8148 | ||
|
|
1e77d0471e | ||
|
|
a94ce72894 | ||
|
|
0f2a4d02e0 | ||
|
|
4d521cea42 | ||
|
|
7d10209198 | ||
|
|
fc8d180f7c | ||
|
|
470e17963a | ||
|
|
4c5dc118aa | ||
|
|
ad84cd848a | ||
|
|
127bd56f73 | ||
|
|
ad3721acf1 | ||
|
|
085127326b | ||
|
|
2e32ab3282 | ||
|
|
8bb125597b | ||
|
|
8ca7f85bf0 | ||
|
|
9d4d36939c | ||
|
|
dabe1c157b | ||
|
|
36d0c4422e | ||
|
|
1429efd573 | ||
|
|
93c03c5676 | ||
|
|
bd1bd007c0 | ||
|
|
f485406c4d | ||
|
|
d96c360016 | ||
|
|
6d71f43b6c | ||
|
|
d6824fcaf6 | ||
|
|
61f388f5f4 | ||
|
|
6955e1ee20 | ||
|
|
7bb4a4bc90 | ||
|
|
6fa01a5d48 | ||
|
|
5a65a46fc1 | ||
|
|
c255355e5c | ||
|
|
8d3781db90 | ||
|
|
f6a87b2ec0 | ||
|
|
e8a1854c5e | ||
|
|
f8d27d8fab | ||
|
|
94dd2712b1 | ||
|
|
a4804f6501 | ||
|
|
5cb3a053fb | ||
|
|
4923b6da68 | ||
|
|
b7e64e09a3 | ||
|
|
76e65a47a2 | ||
|
|
ef644ce319 | ||
|
|
08c4179a7a | ||
|
|
5c3be9c3d6 | ||
|
|
8fc0b49994 | ||
|
|
db4c71368c | ||
|
|
30774bbc40 | ||
|
|
3b48c82c72 | ||
|
|
038c68c594 | ||
|
|
cf222e1105 | ||
|
|
541c87e262 | ||
|
|
5f5c345f94 | ||
|
|
8aa408a3c1 | ||
|
|
4f0337021c | ||
|
|
00062fdd5c | ||
|
|
2bc91c1f21 | ||
|
|
c92a29db1e | ||
|
|
37d67f110a | ||
|
|
bf779f30ab | ||
|
|
4d81124dfa | ||
|
|
ccf8840fa3 | ||
|
|
267aafe861 | ||
|
|
1bdcfb1d83 | ||
|
|
0c69ef9381 | ||
|
|
c99b1cada1 | ||
|
|
83d19ac8ed | ||
|
|
5f77d7f582 | ||
|
|
96f6e75702 | ||
|
|
74c7e49cea | ||
|
|
876bf15a11 | ||
|
|
de60752219 | ||
|
|
5d80ac73da | ||
|
|
0ff32d5cae | ||
|
|
4618b47141 | ||
|
|
94361b2d5d | ||
|
|
d4429ebce1 | ||
|
|
b5711ead25 | ||
|
|
522ddd4e61 | ||
|
|
73365369df | ||
|
|
631b9a5290 | ||
|
|
4ccd39fd55 | ||
|
|
50bc6b2c62 | ||
|
|
609b86acb9 | ||
|
|
1ea84483da | ||
|
|
ed5086823c | ||
|
|
73748a6341 | ||
|
|
1f60300555 | ||
|
|
85a13a9dc0 | ||
|
|
803e73bd1e | ||
|
|
a3356d0188 | ||
|
|
5c21aa2ad4 | ||
|
|
6116830da9 | ||
|
|
650bc2598b | ||
|
|
a36fba061a | ||
|
|
e62b3d390f | ||
|
|
1a8ebf80b5 | ||
|
|
2564f706d8 | ||
|
|
c97c00bf5f | ||
|
|
702b125a48 | ||
|
|
ca9f29f984 | ||
|
|
58b9fa100d | ||
|
|
656b08f3b6 | ||
|
|
3f62677176 | ||
|
|
ae2d98750c | ||
|
|
7d41c1219b | ||
|
|
65e1f1b3a9 | ||
|
|
437b823c84 | ||
|
|
c9f21d5970 | ||
|
|
80c11e7eda | ||
|
|
0745cabc87 | ||
|
|
3e80669f4e | ||
|
|
b81cd9ec61 | ||
|
|
da6ed94443 | ||
|
|
96d41b3716 | ||
|
|
7dddc4d759 | ||
|
|
a87690d817 | ||
|
|
18ef3da261 | ||
|
|
f4698dd5b2 | ||
|
|
d4322a2ed4 | ||
|
|
7260a9d5b4 | ||
|
|
12b4ceb4aa | ||
|
|
fa1cd5c263 | ||
|
|
f8da13912d | ||
|
|
a3b3bf86ba | ||
|
|
a99f7bb87d | ||
|
|
d6f14d02dd | ||
|
|
d18671eaf9 | ||
|
|
87c30d00e8 | ||
|
|
c8f45685b8 | ||
|
|
bb90d80d22 | ||
|
|
dcc541f86e | ||
|
|
aaa36fd8f5 | ||
|
|
2bb14892af | ||
|
|
6d8701665e | ||
|
|
c2b8fdac0d | ||
|
|
059caa4c57 | ||
|
|
51773f5709 | ||
|
|
483404a67f | ||
|
|
68b84dd56b | ||
|
|
7709e1313c | ||
|
|
c952baa672 | ||
|
|
9dfe51eac4 | ||
|
|
5de848bf38 | ||
|
|
295cedc075 | ||
|
|
4f1cab407f | ||
|
|
626a7fdad7 | ||
|
|
9a1da23bdb | ||
|
|
4ffd164461 | ||
|
|
904cc63a72 | ||
|
|
177c36b0d6 | ||
|
|
5fc6bdd478 | ||
|
|
ca6e5fb0a8 | ||
|
|
1a7a446150 | ||
|
|
981d929f50 | ||
|
|
4a3eb642c0 | ||
|
|
a1b0c1a4aa | ||
|
|
0f185a528d | ||
|
|
aef7f3fef8 | ||
|
|
1767586797 | ||
|
|
60be6de9af | ||
|
|
2a7551cca5 | ||
|
|
36439b5252 | ||
|
|
bbee80dbd0 | ||
|
|
a7ea42adc3 | ||
|
|
4dc3b19d2a | ||
|
|
030d8e8dd4 | ||
|
|
401165d0d6 | ||
|
|
ccb209ad37 | ||
|
|
c1a66e0418 | ||
|
|
8491d18413 | ||
|
|
9b835633ab | ||
|
|
fbbc4b8b27 | ||
|
|
74ee1c8c4f | ||
|
|
35604cf151 | ||
|
|
aafcd63a9f | ||
|
|
43a534f05b | ||
|
|
9ec66dac7f | ||
|
|
13fc0ffbca | ||
|
|
93ba6616d1 | ||
|
|
a4b98f38a6 | ||
|
|
b95d08aaea | ||
|
|
b400d49e77 | ||
|
|
e43487155f | ||
|
|
dee3723d97 | ||
|
|
b7e986f43c | ||
|
|
664fb23e97 | ||
|
|
714ef128a1 | ||
|
|
7cf3fce624 | ||
|
|
0cc5431867 | ||
|
|
b8d5b2c8ea | ||
|
|
894ca6d290 | ||
|
|
847b25f695 | ||
|
|
703a05cb15 | ||
|
|
30c194c557 | ||
|
|
cc7b030a41 | ||
|
|
7a91c4d5b7 | ||
|
|
287da6e7e3 | ||
|
|
7cf89764e7 | ||
|
|
d316c72beb | ||
|
|
82d187cc45 | ||
|
|
0c240d21d2 | ||
|
|
009252c831 | ||
|
|
0c1146aaa5 | ||
|
|
4fd06594a0 | ||
|
|
4e175be88f | ||
|
|
771a700acd | ||
|
|
e9bd5da2c3 | ||
|
|
f64244f33a | ||
|
|
ed1417c3e3 | ||
|
|
0398e02690 | ||
|
|
e285bf1a52 | ||
|
|
2c9219d4f7 | ||
|
|
26b3b75054 | ||
|
|
cdb651b68f | ||
|
|
91a36f4421 | ||
|
|
21c1d71551 | ||
|
|
38befdb260 | ||
|
|
63c79173b2 | ||
|
|
d2ad003891 | ||
|
|
eb89773819 | ||
|
|
403abd84f6 | ||
|
|
f62f79c95c | ||
|
|
144c4c9223 | ||
|
|
ab4fc4f459 | ||
|
|
51569ce0a5 | ||
|
|
f191c68efc | ||
|
|
bb8ce6d981 | ||
|
|
e0ee75e0d0 | ||
|
|
1ef3a230a1 | ||
|
|
b1805d4bf1 | ||
|
|
cac979c7fd | ||
|
|
4072dcdda5 | ||
|
|
ed382fff6d | ||
|
|
23bb8277d5 | ||
|
|
8099d6465c | ||
|
|
28a0b9e84e | ||
|
|
9287aaf7ce | ||
|
|
0585f862cb | ||
|
|
7cac6f6f72 | ||
|
|
57be4d798b | ||
|
|
05c74f1997 | ||
|
|
f5e49b6db7 | ||
|
|
3c40e72d27 | ||
|
|
2f2ae7cec5 | ||
|
|
b236b53dc3 | ||
|
|
eb71e30046 | ||
|
|
aa5fd52302 | ||
|
|
d6bc2765b6 | ||
|
|
01258de560 | ||
|
|
3af2cc5c70 | ||
|
|
2278842531 | ||
|
|
60ab00ecc6 | ||
|
|
1fb6d23500 | ||
|
|
8d8a2a5583 | ||
|
|
caa81b4885 | ||
|
|
37c4a0451a | ||
|
|
11df8fcc6c | ||
|
|
5a7f4d8381 | ||
|
|
1f1e4c72ec | ||
|
|
02a5a6b55f | ||
|
|
58ad647d29 | ||
|
|
099073356c | ||
|
|
37038c4a63 | ||
|
|
ffa98e5b34 | ||
|
|
6013d00654 | ||
|
|
c03d63acb8 | ||
|
|
c6689ca07a | ||
|
|
1d0e2d29a7 | ||
|
|
d83d826236 | ||
|
|
2efe687b4b | ||
|
|
7cabc8f328 | ||
|
|
b57bdcaaea | ||
|
|
041643783d | ||
|
|
ed75ef917a | ||
|
|
7ea0885474 | ||
|
|
488e7c4913 | ||
|
|
6af4d41322 | ||
|
|
2b07a21477 | ||
|
|
4a42ff562d | ||
|
|
d4031893cc | ||
|
|
c2e3ab832c | ||
|
|
8c2f3c839f | ||
|
|
df188e6f15 | ||
|
|
12576daf1f | ||
|
|
46aefc0cbe | ||
|
|
9d4f9b4c12 | ||
|
|
3591e6bebd | ||
|
|
e12ba6b15b | ||
|
|
744b05244d | ||
|
|
c22b8fafa6 | ||
|
|
2194cf46e1 | ||
|
|
1a46ac122a | ||
|
|
55f85a81c6 | ||
|
|
5a8cb8a312 | ||
|
|
d641d1fc39 | ||
|
|
df56c65b54 | ||
|
|
1346fcb59e | ||
|
|
50f681ffe8 | ||
|
|
faef614d80 | ||
|
|
db2cd20dcb | ||
|
|
9ef6024291 | ||
|
|
a643a6c0f0 | ||
|
|
82e21b0c21 | ||
|
|
50e298a4f4 | ||
|
|
a0b9c40f6c | ||
|
|
359cf02161 | ||
|
|
6a1f01f876 | ||
|
|
0595acc48f | ||
|
|
46ff8d51dc | ||
|
|
dcd80e11f4 | ||
|
|
e1b1c7db8d | ||
|
|
99041bc593 | ||
|
|
dbd14481ed | ||
|
|
e3160bc717 | ||
|
|
6798958650 | ||
|
|
3a90c572b4 | ||
|
|
eab3c36d83 | ||
|
|
b7fee7b426 | ||
|
|
469955aec9 | ||
|
|
d1c9dff2c5 | ||
|
|
9491ebbe90 | ||
|
|
09b50383d7 | ||
|
|
8517eef3fe | ||
|
|
8955e31a1e | ||
|
|
f7a3971c64 | ||
|
|
552079d3c2 | ||
|
|
59d984e25d | ||
|
|
d17b9322b7 | ||
|
|
12bc175776 | ||
|
|
376458efa8 | ||
|
|
886984861f | ||
|
|
b248b6bc12 | ||
|
|
8d0d0d61f1 | ||
|
|
26c348520f | ||
|
|
ec79386306 | ||
|
|
093ac6fb16 | ||
|
|
68e2c511b7 | ||
|
|
47c82b42d9 | ||
|
|
e1a3b48c6e | ||
|
|
b8b17ae473 | ||
|
|
b203344ed4 | ||
|
|
6d30a45017 | ||
|
|
3f9863c441 | ||
|
|
47294ef6b8 | ||
|
|
2356d7c629 | ||
|
|
4908a0aa9e | ||
|
|
6032764052 | ||
|
|
908a41814b | ||
|
|
3ae145bd60 | ||
|
|
b086a73353 | ||
|
|
794e254d90 | ||
|
|
760c5737f9 | ||
|
|
ea17eee320 | ||
|
|
09ce79bd43 | ||
|
|
2dfd17af4a | ||
|
|
a9975e524b | ||
|
|
5d062285c2 | ||
|
|
e4b0f3ced5 | ||
|
|
6545bb9edb | ||
|
|
70ce6eff9e | ||
|
|
c84d96abee | ||
|
|
09eb42e5c6 | ||
|
|
06388b514c | ||
|
|
7c17a4067c | ||
|
|
48ada8e8ca | ||
|
|
282bcf6f34 | ||
|
|
1446d1acf8 | ||
|
|
aae94ffae3 | ||
|
|
ebd906a45d | ||
|
|
e30beb9c9f | ||
|
|
5a2e297991 | ||
|
|
ae1e7fbaa0 | ||
|
|
7e616a4056 | ||
|
|
c9e192564c | ||
|
|
47550d48e7 | ||
|
|
5cc76f48aa | ||
|
|
3ceaa8bd20 | ||
|
|
790b9bbf01 | ||
|
|
ee1016523f | ||
|
|
2f51778421 | ||
|
|
d3d68c2a60 | ||
|
|
0628b3e41c | ||
|
|
eac183495a | ||
|
|
0ae02da9be | ||
|
|
90fe634ddd | ||
|
|
1cae841ed6 | ||
|
|
a88560e557 | ||
|
|
3ca9a66323 | ||
|
|
3a4a55c245 | ||
|
|
f4a243861c | ||
|
|
68209f270e | ||
|
|
8af939d320 | ||
|
|
16a50935ea | ||
|
|
24ea10c451 | ||
|
|
95abda4870 | ||
|
|
c8993c4da8 | ||
|
|
d096798340 | ||
|
|
f2f5e0e26f | ||
|
|
9121032114 | ||
|
|
541bf968e5 | ||
|
|
8c3ebdcbab | ||
|
|
0593e9e89f | ||
|
|
0d412c88fd | ||
|
|
8280106493 | ||
|
|
a3e106fe04 | ||
|
|
981a04f33b | ||
|
|
26025e5abd | ||
|
|
4839a5ba70 | ||
|
|
85a1550485 | ||
|
|
77658415b2 | ||
|
|
68f27be7cd | ||
|
|
6460327372 | ||
|
|
a96c0ec7a3 | ||
|
|
33c0a27b85 | ||
|
|
d5b39cd496 | ||
|
|
5a35d69ed0 | ||
|
|
e1e94a33e2 | ||
|
|
18c9b177f3 | ||
|
|
1970741049 | ||
|
|
f16428ce2a | ||
|
|
14427523ae | ||
|
|
236b0496d3 | ||
|
|
f68ddf66e9 | ||
|
|
20685b6d69 | ||
|
|
4988b4e0f5 | ||
|
|
651e444875 | ||
|
|
2093fed554 | ||
|
|
cc7bb8b549 | ||
|
|
a99ac14c6a | ||
|
|
2c163352c3 | ||
|
|
fb6c4eca34 | ||
|
|
142e2cbe9d | ||
|
|
c70dd119d3 | ||
|
|
9e312cbdfa | ||
|
|
4d87b741cd | ||
|
|
d08bc4c413 | ||
|
|
f5d3d1e65d | ||
|
|
916314233f | ||
|
|
c7e6ee7297 | ||
|
|
557a6ecd4f | ||
|
|
0e04cac800 | ||
|
|
1db9258d39 | ||
|
|
2803c2acdb | ||
|
|
8be43566a4 | ||
|
|
d865cec2a4 | ||
|
|
6f0370a073 | ||
|
|
5b9f8177f2 | ||
|
|
6967e4e54b | ||
|
|
96fb0ac3ae | ||
|
|
7201938793 | ||
|
|
cdd6f78c73 | ||
|
|
ab94d3045d | ||
|
|
ff4e2bdfb7 | ||
|
|
ffed19d198 | ||
|
|
49d6a5e32d | ||
|
|
3ba7ba4f92 | ||
|
|
43ffc996db | ||
|
|
27f5f94c60 | ||
|
|
1d9734c824 | ||
|
|
717fb57a14 | ||
|
|
af1d21c225 | ||
|
|
3c8b2a82a3 | ||
|
|
0cb6f662c6 | ||
|
|
739ed56b4c | ||
|
|
9a19ef82fd | ||
|
|
5627ed141b | ||
|
|
e4a2af67b1 | ||
|
|
cba56f3263 | ||
|
|
1c3cf39b8a | ||
|
|
6421438f64 | ||
|
|
8a63682c16 | ||
|
|
13a8c6256d | ||
|
|
8c9cc4cce5 | ||
|
|
0023cb2521 | ||
|
|
010ed77345 | ||
|
|
00c11f5dd0 | ||
|
|
637e424506 | ||
|
|
eb55b80bdc | ||
|
|
73812b06be | ||
|
|
d135957f0d | ||
|
|
815fd44ab3 | ||
|
|
4e8a48ab3d | ||
|
|
ea75c39b58 | ||
|
|
085f013bf9 | ||
|
|
c6b8d890e5 | ||
|
|
66783c9381 | ||
|
|
b32e67ff9e | ||
|
|
ba55d6caeb | ||
|
|
1eba04d37b | ||
|
|
ecbb2f1399 | ||
|
|
93f4a91ebf | ||
|
|
a1ac5bd74c | ||
|
|
0ca9e973ad | ||
|
|
ee5df0e11c | ||
|
|
d53a6e4c42 | ||
|
|
744042e8c8 | ||
|
|
444f6ca826 | ||
|
|
0ea13ec528 | ||
|
|
6845ba9b90 | ||
|
|
2f890f7bb3 | ||
|
|
3fefb24d71 | ||
|
|
d6f890c7b9 | ||
|
|
59e0137816 | ||
|
|
dd4bc23e4f | ||
|
|
09fd5e8819 | ||
|
|
81f3ba17c7 | ||
|
|
3288d3d538 | ||
|
|
7e861f388f | ||
|
|
1323b42169 | ||
|
|
a843f1af6c | ||
|
|
52b759c009 | ||
|
|
f2f5815316 | ||
|
|
2da00e162a | ||
|
|
e53a9f3f1a | ||
|
|
767f6a90e0 | ||
|
|
562b495a18 | ||
|
|
084607f359 | ||
|
|
eeeb565313 | ||
|
|
1fa31b3974 | ||
|
|
7158a504fa | ||
|
|
3ccd7508ac | ||
|
|
fc6075f19c | ||
|
|
776c147ea4 | ||
|
|
5d9641ae86 | ||
|
|
88a1f951c2 | ||
|
|
1726a1d5f4 | ||
|
|
6c563a3f13 | ||
|
|
6a1f4906c5 | ||
|
|
ef716aacc2 | ||
|
|
c91242ed60 | ||
|
|
e536a40740 | ||
|
|
5aaf4cad20 | ||
|
|
1c167ec150 | ||
|
|
9898e18ae2 | ||
|
|
b182c3d86d | ||
|
|
96a4d4c8ac | ||
|
|
9d19fc9ecc | ||
|
|
aad12670b2 | ||
|
|
7ca7fe7c13 | ||
|
|
ca7e7c288e | ||
|
|
3a604464b5 | ||
|
|
e277281d18 | ||
|
|
3d339696dc | ||
|
|
3e36a49142 | ||
|
|
d8f53954d0 | ||
|
|
8c984cbf42 | ||
|
|
60cdcf5f0c | ||
|
|
5afcd634b6 | ||
|
|
00ca58ec13 | ||
|
|
bbb6d448db | ||
|
|
45fad7a6a9 | ||
|
|
7633a9b07a | ||
|
|
00d0dba62c | ||
|
|
c9a396b9e3 | ||
|
|
fc0a7b7657 | ||
|
|
c1e870d8f5 | ||
|
|
190f2a7fc2 | ||
|
|
ce0ccf4fd0 | ||
|
|
0018e0bec6 | ||
|
|
63021e0ca3 | ||
|
|
bf741df38e | ||
|
|
e627d4e2c4 | ||
|
|
9b5a62e60f | ||
|
|
3bb1eab48c | ||
|
|
4af576668c | ||
|
|
f5a93574f6 | ||
|
|
74f8889bfa | ||
|
|
a8da0f64ac | ||
|
|
44c3b046dd | ||
|
|
f2cb04817b | ||
|
|
a5f60b1522 | ||
|
|
0e3dccd9f6 | ||
|
|
b21b50873f | ||
|
|
d335b7a033 | ||
|
|
f3b22e04e8 | ||
|
|
712f3affd9 | ||
|
|
89292e238b | ||
|
|
3287085ef9 | ||
|
|
17dfd914d5 | ||
|
|
c65fe49983 | ||
|
|
6552d90dc9 | ||
|
|
d925e8af9e | ||
|
|
2c0fc43137 | ||
|
|
ccdbec088f | ||
|
|
9822d17ab9 | ||
|
|
9573d9e385 | ||
|
|
155f3d6231 | ||
|
|
1959ca2d96 | ||
|
|
324913d2da | ||
|
|
5c4cafcb6f | ||
|
|
1ea8d69b40 | ||
|
|
013e45596e | ||
|
|
3fdb691702 | ||
|
|
997129871c | ||
|
|
39b1935350 | ||
|
|
dbb9a8dcf6 | ||
|
|
cacd4afbbb | ||
|
|
9c5877aa31 | ||
|
|
bda4788a34 | ||
|
|
5abfef50fc | ||
|
|
e3ee5c1f2e | ||
|
|
e6eb702a88 | ||
|
|
1447819198 | ||
|
|
adf5795dff | ||
|
|
584fd06b88 | ||
|
|
1faa1a5abc | ||
|
|
b5db8eba06 | ||
|
|
c6843c1eae | ||
|
|
93b7fd589e | ||
|
|
eb80305f87 | ||
|
|
865e3c5bde | ||
|
|
c2270e57df | ||
|
|
d48c031548 | ||
|
|
830a07012b | ||
|
|
46227295ff | ||
|
|
73fb1fc2ed | ||
|
|
7ff48155d6 | ||
|
|
0adaa331a1 | ||
|
|
30ec06ca76 | ||
|
|
9b5c6e538b | ||
|
|
240a406964 | ||
|
|
38d25f9a9b | ||
|
|
7b1b6fa1cf | ||
|
|
a3d9af132f | ||
|
|
5d8b566a27 | ||
|
|
8c30a359e7 | ||
|
|
087c2b61ee | ||
|
|
a9117010f9 | ||
|
|
9d980f36b0 | ||
|
|
93515e5a0f | ||
|
|
d550c69f7f | ||
|
|
087e192fac | ||
|
|
63e45563ec | ||
|
|
961d6d0a5c | ||
|
|
f7aacefc40 | ||
|
|
42e920cd5c | ||
|
|
02ff3f2ff4 | ||
|
|
ca2845bcb0 | ||
|
|
80cd5d9ccc | ||
|
|
0eb7db8de5 | ||
|
|
100955a7db | ||
|
|
68814813c3 | ||
|
|
39590f1b28 | ||
|
|
92698efd39 | ||
|
|
b693cb98d0 | ||
|
|
3ed142d0a9 | ||
|
|
581e61a85b | ||
|
|
764b200289 | ||
|
|
26503dffdf | ||
|
|
b284e95394 | ||
|
|
b2211de8d8 | ||
|
|
1bb0eb0e70 | ||
|
|
0a1161048f | ||
|
|
7a6ce00fed | ||
|
|
c0aa9ced8d | ||
|
|
6b36df3f8f | ||
|
|
df3caeb04a | ||
|
|
0e267509da | ||
|
|
bbe41278ed | ||
|
|
d91a6b0c38 | ||
|
|
694a93db6d | ||
|
|
2f2dec87b1 | ||
|
|
098288c290 | ||
|
|
ab2e6bb9a3 | ||
|
|
513f19370a | ||
|
|
e20a4c1f77 | ||
|
|
946a486c4b | ||
|
|
78b40397f9 | ||
|
|
48e8d1c12f | ||
|
|
b115e95da4 | ||
|
|
ab0892cc41 | ||
|
|
e25291c74c | ||
|
|
0e552bd602 | ||
|
|
e9b3e15556 | ||
|
|
237d0fd4e2 | ||
|
|
50bd30fb1f | ||
|
|
9fb4e2d272 | ||
|
|
3e51366921 | ||
|
|
2d184b1ab6 | ||
|
|
befcdf55fe | ||
|
|
ba12d39121 | ||
|
|
bf0f553ced | ||
|
|
cd68a674bb | ||
|
|
315fc00eac | ||
|
|
6142dcc7e6 | ||
|
|
c47141ffda | ||
|
|
6c8566db60 | ||
|
|
aa60fae3b1 | ||
|
|
8cb9c60a3c | ||
|
|
dd7a20a774 | ||
|
|
374fe087bc | ||
|
|
11691c3122 | ||
|
|
8d129b10ca | ||
|
|
d6e03f50b9 | ||
|
|
f60c9f2a15 | ||
|
|
1c617284f3 | ||
|
|
5bc6ff0e77 | ||
|
|
020c21f4ef | ||
|
|
bd57c1c7e7 | ||
|
|
f4b94a7a89 | ||
|
|
b666b66160 | ||
|
|
8bafb1a641 | ||
|
|
42d4574213 | ||
|
|
9ef6f8aec9 | ||
|
|
556eec649d | ||
|
|
e160025cfc | ||
|
|
0602149c52 | ||
|
|
ad17c6e40d | ||
|
|
b95a766888 | ||
|
|
a0770db179 | ||
|
|
0601f6a35c | ||
|
|
d6acfa56c2 | ||
|
|
11601fd091 | ||
|
|
d78c8370b6 | ||
|
|
0b752409d5 | ||
|
|
46e0f5da74 | ||
|
|
d835a2a450 | ||
|
|
f09cc03164 | ||
|
|
4c628b1cd9 | ||
|
|
5122271750 | ||
|
|
7df978390f | ||
|
|
564dba3053 | ||
|
|
3b4d445ca8 | ||
|
|
176a15dace | ||
|
|
9249cf240e | ||
|
|
a049eda7e6 | ||
|
|
9eafa118d5 | ||
|
|
8699d94de6 | ||
|
|
4c0a5ac3b2 | ||
|
|
fa51793379 | ||
|
|
8b99df3169 | ||
|
|
7cbbf73cc9 | ||
|
|
0b1ec1e50b | ||
|
|
2744d33ef8 | ||
|
|
659ac2c107 | ||
|
|
5892dc71fa | ||
|
|
fc3e547dce | ||
|
|
e4f9f949f0 | ||
|
|
7605462d48 | ||
|
|
fd10b9723d | ||
|
|
6cdfb7ab63 | ||
|
|
e5fdab1bc8 | ||
|
|
2aa1eee29d | ||
|
|
97e566d470 | ||
|
|
a8eaf2d0ad | ||
|
|
b5f9564e13 | ||
|
|
7e353f8ea0 | ||
|
|
0075e94a42 | ||
|
|
1ea9e38fea | ||
|
|
3b405a53d0 | ||
|
|
84c329e911 | ||
|
|
4349ceaf0e | ||
|
|
acdf37561f | ||
|
|
9128d4cc49 | ||
|
|
206e97d374 | ||
|
|
f682af2fe0 | ||
|
|
ecf7e60d98 | ||
|
|
68ddc070ca | ||
|
|
d661da8d7e | ||
|
|
5d2e8cb000 | ||
|
|
096bb8e6e5 | ||
|
|
1af8bb494e | ||
|
|
46d04d9d1a | ||
|
|
c0ca4ffbcc | ||
|
|
8720b6db95 | ||
|
|
8c61d45206 | ||
|
|
0e4625ef88 | ||
|
|
10d559bbb5 | ||
|
|
65b2892de5 | ||
|
|
6fa6c3c81c | ||
|
|
e4ffc932a9 | ||
|
|
8afc0e6ab2 | ||
|
|
822092044b | ||
|
|
f1c153f39f | ||
|
|
7e62dc64dc | ||
|
|
2104a60703 | ||
|
|
97785fa570 | ||
|
|
9341fe9584 | ||
|
|
3a582721cf | ||
|
|
3d96d73169 | ||
|
|
542422b7b8 | ||
|
|
c835d85256 | ||
|
|
56ada7f0e9 | ||
|
|
56fdebde75 | ||
|
|
4ee67064bb | ||
|
|
045ec9689d | ||
|
|
4ebad2c473 | ||
|
|
1fe6dac760 | ||
|
|
f12a6ff73f | ||
|
|
6eed458ceb | ||
|
|
54fb0a6acb | ||
|
|
5147d9cb6d | ||
|
|
37369929f3 | ||
|
|
4f10014902 | ||
|
|
0ef3e00ba7 | ||
|
|
2408590430 | ||
|
|
b7f4fe4d73 | ||
|
|
b811492acd | ||
|
|
a63e0e0390 | ||
|
|
5e8a0b2cfa | ||
|
|
eac75aad03 | ||
|
|
b05fbc2102 | ||
|
|
6d166fdfc5 | ||
|
|
2e36673702 | ||
|
|
0c81ffe8b7 | ||
|
|
02b9ceb4c7 | ||
|
|
775889c0b6 | ||
|
|
98f2cdaf5a | ||
|
|
ff5cc3cb4f | ||
|
|
ebecb1caec | ||
|
|
73e0aea85c | ||
|
|
1a09f5807b | ||
|
|
ec009a2bba | ||
|
|
f52c40a492 | ||
|
|
1959c059ed | ||
|
|
2d1610b075 | ||
|
|
2f76738b50 | ||
|
|
1cf174a613 | ||
|
|
d743454d07 | ||
|
|
1cd16eaa08 | ||
|
|
90e622b307 | ||
|
|
cb5cd64c05 | ||
|
|
2619569549 | ||
|
|
d306cafbcc | ||
|
|
f5ce34fb69 | ||
|
|
dbeba4f173 | ||
|
|
86f83635bc | ||
|
|
fceda00d83 | ||
|
|
9b7af00cf5 | ||
|
|
fa1281ae86 | ||
|
|
f5de4d7b71 | ||
|
|
1134df88e2 | ||
|
|
4aadabfac0 | ||
|
|
c27898a993 | ||
|
|
daa897db93 | ||
|
|
7a907bb44d | ||
|
|
d7cb219577 | ||
|
|
b28f8b0e7f | ||
|
|
51721dde50 | ||
|
|
09547ba788 | ||
|
|
3dc8acc385 | ||
|
|
0414da8c32 | ||
|
|
155450380e | ||
|
|
09bc4ef1d6 | ||
|
|
3aa4d8713c | ||
|
|
5fc926271f | ||
|
|
f435b612c9 | ||
|
|
5b78c0d3e0 | ||
|
|
6a14bf70e0 | ||
|
|
138b368951 | ||
|
|
0871d6ebc1 | ||
|
|
ad5ef76e8e | ||
|
|
2f55747601 | ||
|
|
b376458963 | ||
|
|
64ac22a918 | ||
|
|
ffb81e4ff7 | ||
|
|
01743e5c88 | ||
|
|
4ef6266e8f | ||
|
|
478a8362b8 | ||
|
|
afa1899dc9 | ||
|
|
cea2abcf6e | ||
|
|
c7d1ad56ff | ||
|
|
a5f490cc53 | ||
|
|
abe29fa6ee | ||
|
|
f6d1e566e7 | ||
|
|
9ec4f0b2f5 | ||
|
|
1678045ce4 | ||
|
|
7286e724dc | ||
|
|
e59a1e9efd | ||
|
|
097bedcb9b | ||
|
|
907ff89011 | ||
|
|
08faa0c009 | ||
|
|
dd4759487b | ||
|
|
7980da9ce5 | ||
|
|
0e43524dac | ||
|
|
c5c5f642e8 | ||
|
|
e096c608ee | ||
|
|
9a2bfe1180 | ||
|
|
9e36cabef0 | ||
|
|
ce1c5be940 | ||
|
|
1182d159aa | ||
|
|
7d95926f02 | ||
|
|
101ecf342f | ||
|
|
4efba94662 | ||
|
|
1855d661e8 | ||
|
|
438abc4cf9 | ||
|
|
40639f70f4 | ||
|
|
a80c020146 | ||
|
|
2ce3270d21 | ||
|
|
4d8fe0b6b2 | ||
|
|
411087ff1a | ||
|
|
10bb2a6a10 | ||
|
|
7aff81547a | ||
|
|
fc097db2a0 | ||
|
|
843151859d | ||
|
|
854ab353b3 | ||
|
|
cc6ec1d351 | ||
|
|
cf307db31d | ||
|
|
dcfca4d95e | ||
|
|
567c368a81 | ||
|
|
223b2fc263 | ||
|
|
4a28ab6317 | ||
|
|
0986ce12e6 | ||
|
|
37aa3b8e49 | ||
|
|
d7f14339fe | ||
|
|
0e4be0c85a | ||
|
|
b6f8bca361 | ||
|
|
354c72968e | ||
|
|
9d3e3c7312 | ||
|
|
88e2687e23 | ||
|
|
19944bfdb2 | ||
|
|
c8efcf5105 | ||
|
|
7f6da52349 | ||
|
|
8999f0104f | ||
|
|
516c481e94 | ||
|
|
3266c2cd8f | ||
|
|
f0dcd8e07b | ||
|
|
9ef1fee172 | ||
|
|
b3bd4ccc17 | ||
|
|
fba7686390 | ||
|
|
2d314e5309 | ||
|
|
ed72d7f9ec | ||
|
|
b8f64fe3d4 | ||
|
|
ab64828661 | ||
|
|
10dfa18e81 | ||
|
|
a38bf25e68 | ||
|
|
419ec6e308 | ||
|
|
ada589d0c3 | ||
|
|
7068d27a8b | ||
|
|
a302275187 | ||
|
|
b734d58ab7 | ||
|
|
2046b02bd8 | ||
|
|
1df824db7c | ||
|
|
9cad2c6b7d | ||
|
|
82881c030a | ||
|
|
00ca7d5942 | ||
|
|
d36df3eaa9 | ||
|
|
e5d654f0c7 | ||
|
|
fc1f471369 | ||
|
|
be6f4e38b8 | ||
|
|
faa8674f39 | ||
|
|
2dc707d86e | ||
|
|
05a92494bb | ||
|
|
39fd955f13 | ||
|
|
4863e1d227 | ||
|
|
44ad9d4f5f | ||
|
|
2b652fe2a9 | ||
|
|
cdd2082b07 | ||
|
|
5c74aed8f6 | ||
|
|
5b97bc04e0 | ||
|
|
63c8b275d1 | ||
|
|
1ebc17352f | ||
|
|
268c8382ee | ||
|
|
498dcbbfe8 | ||
|
|
3a1ecb342f | ||
|
|
bb0da69c9e | ||
|
|
796dce3cd3 | ||
|
|
f59c34004d | ||
|
|
c4cbf0d618 | ||
|
|
d002e5dda8 | ||
|
|
a9d0ab271d | ||
|
|
89cb821c97 | ||
|
|
ef8c520b59 | ||
|
|
8897fd75ad | ||
|
|
fd748c1dc3 | ||
|
|
c95dbf7508 | ||
|
|
ed64c38950 | ||
|
|
0b5be8cdcd | ||
|
|
fcc77052a6 | ||
|
|
831c9ff5bf | ||
|
|
de37141812 | ||
|
|
c35a648734 | ||
|
|
a550caf63f | ||
|
|
de9eaa98db | ||
|
|
37b657cbbd | ||
|
|
a733f5c615 | ||
|
|
8a587d1d12 | ||
|
|
75bb22f08b | ||
|
|
d10da39e5b | ||
|
|
54e9b839bd | ||
|
|
aec6ac019f | ||
|
|
075a08884b | ||
|
|
6fcb2ab5dd | ||
|
|
7f0f045f29 | ||
|
|
e7d1eadf8e | ||
|
|
a9b5359f7c | ||
|
|
9df6e19204 | ||
|
|
5eaae184c9 | ||
|
|
459882e6fa | ||
|
|
2c2b5d555e | ||
|
|
0ab2428d87 | ||
|
|
8574494573 | ||
|
|
a4d4a9c686 | ||
|
|
3d32b68bb2 | ||
|
|
fd9eb462cc | ||
|
|
f9533e016f | ||
|
|
85b15fa63b | ||
|
|
e236842888 | ||
|
|
3dadaf9334 | ||
|
|
9e510a678c | ||
|
|
2dc0ea2b89 | ||
|
|
7d364ca7ce | ||
|
|
9f6a6d7f5b | ||
|
|
3cc740cda3 | ||
|
|
47b24b5dff | ||
|
|
8f100a792e | ||
|
|
84c6731ddf | ||
|
|
1f1de353de | ||
|
|
40eb82adbe | ||
|
|
d9240e1e2e | ||
|
|
9abaed8385 | ||
|
|
95e83c52fa | ||
|
|
be377dcda8 | ||
|
|
88a68e883e | ||
|
|
2ad5d33251 | ||
|
|
4e5dd914dd | ||
|
|
2adf1e5017 | ||
|
|
55ca4e93c4 | ||
|
|
d1d03f45c5 | ||
|
|
436bd891bd | ||
|
|
a7c28fe5ed | ||
|
|
60814d1ff0 | ||
|
|
d018efe2a5 | ||
|
|
6fd0cba06a | ||
|
|
86f9322036 | ||
|
|
12c6af23ee | ||
|
|
69330f47fd | ||
|
|
4f40c128bf | ||
|
|
30b5ad1515 | ||
|
|
665a26d164 | ||
|
|
a5774bf6ff | ||
|
|
d2716fe5cf | ||
|
|
279f877bf2 | ||
|
|
d51e6a43e7 | ||
|
|
6a96756c87 | ||
|
|
df69d9f195 | ||
|
|
26ffa19f36 | ||
|
|
29ef3f0b41 | ||
|
|
106d5e54c7 | ||
|
|
6ac2460eb0 | ||
|
|
79c030b138 | ||
|
|
c8d649e8c2 | ||
|
|
1fdf82dd6c | ||
|
|
4aa4246695 | ||
|
|
1bebceb29c | ||
|
|
a2139ee236 | ||
|
|
8c55f39cdf | ||
|
|
0329184c94 | ||
|
|
cd64390141 | ||
|
|
3e12a8780d | ||
|
|
11e6ff1bbe | ||
|
|
36f85fc97e | ||
|
|
9040cfd200 | ||
|
|
757da3b15a | ||
|
|
d162590a32 | ||
|
|
f41e1716c6 | ||
|
|
4dce0f1b9d | ||
|
|
fef57dce0d | ||
|
|
d884700b61 | ||
|
|
ff9ad4bd1d | ||
|
|
9ce2b7555c | ||
|
|
f90ccd3391 | ||
|
|
5ff092e541 | ||
|
|
dcdf401f64 | ||
|
|
e4fb80b39b | ||
|
|
9745854ab8 | ||
|
|
7124621f66 | ||
|
|
47fd8f5793 | ||
|
|
40d698f2db | ||
|
|
74abe98706 | ||
|
|
86787f3bc8 | ||
|
|
699b0c775a | ||
|
|
ff59ef8094 | ||
|
|
089af7cc1f | ||
|
|
1591a2d9a3 | ||
|
|
f7984ed642 | ||
|
|
be634c6043 | ||
|
|
d1f68eacd9 | ||
|
|
4f45f23094 | ||
|
|
c5dc01ee11 | ||
|
|
587c385936 | ||
|
|
3a641a58b0 | ||
|
|
e944306a28 | ||
|
|
3b44ed6d16 | ||
|
|
0965ab8063 | ||
|
|
fcae100df1 | ||
|
|
24a7762873 | ||
|
|
e441ab60a2 | ||
|
|
50c2bc5edb | ||
|
|
2ab14ca59e | ||
|
|
4475d65780 | ||
|
|
b1d10f5817 | ||
|
|
36664f37de | ||
|
|
c838df90ef | ||
|
|
fb39af67e5 | ||
|
|
2d4d37f96a | ||
|
|
84af984c4b | ||
|
|
26adf20ee8 | ||
|
|
72668ed0a2 | ||
|
|
50f1ed7851 | ||
|
|
cf8f2a3463 | ||
|
|
b483159b3a | ||
|
|
480abebf7e | ||
|
|
b924dea045 | ||
|
|
2c1e7e5ed6 | ||
|
|
4dfd74906c | ||
|
|
fdae6ad94f | ||
|
|
c80225a18c | ||
|
|
0e6242373e | ||
|
|
4305db5579 | ||
|
|
36e7772f74 | ||
|
|
ca05df5172 | ||
|
|
422e8e6f3e | ||
|
|
852b285d84 | ||
|
|
6c13193623 | ||
|
|
61809107c8 | ||
|
|
6bda9d8604 | ||
|
|
1428ca73de | ||
|
|
498ace0488 | ||
|
|
f082b95efb | ||
|
|
4b8fc2950f | ||
|
|
595cc55578 | ||
|
|
91b0c368b4 | ||
|
|
21d0ffc990 | ||
|
|
55b9d84956 | ||
|
|
ffdb0db6c6 | ||
|
|
a5ed07a666 | ||
|
|
da02c90bad | ||
|
|
3820a231ec | ||
|
|
408b065b9e | ||
|
|
238ab84749 | ||
|
|
6894015986 | ||
|
|
f5080f9bd6 | ||
|
|
db4aa99ce0 | ||
|
|
70134507f8 | ||
|
|
360a4793ae | ||
|
|
47bfb25f2c | ||
|
|
b048b0bf65 | ||
|
|
394f9929ad | ||
|
|
bf39be3320 | ||
|
|
4a2cbb9ec7 | ||
|
|
cc6cf8194f | ||
|
|
e934ead85c | ||
|
|
323bfd9a6e | ||
|
|
bf05e47e26 | ||
|
|
d18f576239 | ||
|
|
7d483c711a | ||
|
|
61256d49cd | ||
|
|
184cdc0331 | ||
|
|
ed972a0037 | ||
|
|
a62a6c1cb6 | ||
|
|
ba0c6be3e3 | ||
|
|
f66566aa17 | ||
|
|
b6ecfc7131 | ||
|
|
460dc6224c | ||
|
|
2b688b1a60 | ||
|
|
3c64d9292f | ||
|
|
0f52d2e464 | ||
|
|
1e5fadc440 | ||
|
|
f495ff483a | ||
|
|
4e3b1509a8 | ||
|
|
d1a80cc880 | ||
|
|
e1ad25cee0 | ||
|
|
195f23c347 | ||
|
|
ad6b99be6a | ||
|
|
b9dd9fc47d | ||
|
|
19a8a80a30 | ||
|
|
637792c6d4 | ||
|
|
4d1bca2d97 | ||
|
|
f33a2eba50 | ||
|
|
5d6bea5ec9 | ||
|
|
057d1f07a8 | ||
|
|
25c3f55672 | ||
|
|
c9d4091c1e | ||
|
|
1d55562dc3 | ||
|
|
95bb9a9780 | ||
|
|
06c391cbf6 | ||
|
|
d90dff95b1 | ||
|
|
c93972a322 | ||
|
|
ca47a7b663 | ||
|
|
9d3d4a3698 | ||
|
|
3b509bf820 | ||
|
|
5b7f91827a | ||
|
|
a44491714c | ||
|
|
06800043a9 | ||
|
|
3090de56b8 | ||
|
|
a37acd1f42 | ||
|
|
372e3f83d2 | ||
|
|
de260a2bef | ||
|
|
e9a130f976 | ||
|
|
43f17414ff | ||
|
|
b259eea8ce | ||
|
|
9cfc2ba09a | ||
|
|
bb347999ce | ||
|
|
3548c3df15 | ||
|
|
1167d0ac2e | ||
|
|
f738bc97e7 | ||
|
|
3f9edfe597 | ||
|
|
a024949311 | ||
|
|
609c901867 | ||
|
|
4ce060a963 | ||
|
|
c4ca0fee40 | ||
|
|
8d4acf0330 | ||
|
|
28a981f29f | ||
|
|
c29113d17a | ||
|
|
951f978447 | ||
|
|
07899f35bd | ||
|
|
3cbbf37468 | ||
|
|
6c7a3df5ae | ||
|
|
2054ab2771 | ||
|
|
44145073f1 | ||
|
|
feb933b4df | ||
|
|
c7cc3002d5 | ||
|
|
049b901d63 | ||
|
|
3cf1b92dfc | ||
|
|
5b0fcbe854 | ||
|
|
cca747a1f6 | ||
|
|
417d99a17e | ||
|
|
e9708b9259 | ||
|
|
e5d3be16b0 | ||
|
|
2ab3c97ee8 | ||
|
|
f20d3043d6 | ||
|
|
4efda89358 | ||
|
|
1fb88271e5 | ||
|
|
a843780f68 | ||
|
|
5ad83da4e0 | ||
|
|
949cc9e214 | ||
|
|
50d92265ea | ||
|
|
e084a9f2b6 | ||
|
|
664f9f36e1 | ||
|
|
4c9efdb936 | ||
|
|
45848e7bfe | ||
|
|
4fa10e5783 | ||
|
|
fc0bc85f4d | ||
|
|
5ae2e5281a | ||
|
|
34a943832a | ||
|
|
db17693ba7 | ||
|
|
6cdf8ebd2c | ||
|
|
072b470f46 | ||
|
|
78b2df2ecc | ||
|
|
51a825f25c | ||
|
|
00e72a30c9 | ||
|
|
69990c23a5 | ||
|
|
df421e0182 | ||
|
|
ede9297139 | ||
|
|
85383fe581 | ||
|
|
4cca7aa4bd | ||
|
|
e2037dea6c | ||
|
|
f10f772e94 | ||
|
|
9ecfe15ac4 | ||
|
|
5f0726af8a | ||
|
|
331bbdd4e6 | ||
|
|
37e3bcfc3e | ||
|
|
4f42c10d60 | ||
|
|
20392a567b | ||
|
|
c03249b411 | ||
|
|
22e6584402 | ||
|
|
c18aca9215 | ||
|
|
aa23a5422a | ||
|
|
01fde4f9ca | ||
|
|
3980dec123 | ||
|
|
c97f837f45 | ||
|
|
9c54d2407b | ||
|
|
a027c4ce1f | ||
|
|
b1fd025ea6 | ||
|
|
a05a230085 | ||
|
|
8fbc1dac74 | ||
|
|
f46842c6c9 | ||
|
|
8b95bb0c03 | ||
|
|
202dd8e92d | ||
|
|
1da3f96d10 | ||
|
|
5f6fe4d670 | ||
|
|
a74438d1ee | ||
|
|
c8033f875d | ||
|
|
07c04006df | ||
|
|
521900c048 | ||
|
|
9069c5abb6 | ||
|
|
ff7a5f471b | ||
|
|
42a47406cc | ||
|
|
de10b6de7b | ||
|
|
d6ade0e1ac | ||
|
|
e04b5e5c9f | ||
|
|
15a6c46d47 | ||
|
|
cb1fc734c2 | ||
|
|
db7f18aae7 | ||
|
|
7fbc327591 | ||
|
|
84b56ae1b2 | ||
|
|
041aa8639a | ||
|
|
216ac72ad0 | ||
|
|
c85ddaeb9c | ||
|
|
e09dec330a | ||
|
|
8f7bae54fe | ||
|
|
ce60f13320 | ||
|
|
1ac0140666 | ||
|
|
6cc8b147a9 | ||
|
|
e078161e2f | ||
|
|
7764185c57 | ||
|
|
d4ef2adf0a | ||
|
|
a83378a44e | ||
|
|
a4a4204762 | ||
|
|
acd1140ef6 | ||
|
|
fbf71c93ff | ||
|
|
38bc0c466a | ||
|
|
71e4351743 | ||
|
|
387e4b94b4 | ||
|
|
201c76b861 | ||
|
|
1c3aa87ca6 | ||
|
|
db63ff6b88 | ||
|
|
115431a486 | ||
|
|
d47ff9b7c7 | ||
|
|
b0818148cf | ||
|
|
2bc4412d66 | ||
|
|
6a428b4da9 | ||
|
|
7299067829 | ||
|
|
5659cb2820 | ||
|
|
570aa4b9e2 | ||
|
|
c4079a3b11 | ||
|
|
6b38b538f1 | ||
|
|
ba139dddd8 | ||
|
|
38b581a231 | ||
|
|
3c2675b41a | ||
|
|
0f5c62ade5 | ||
|
|
54bc3bce96 | ||
|
|
3d92e5b8a9 | ||
|
|
325d145ac3 | ||
|
|
b0654a416a | ||
|
|
19930ec2e4 | ||
|
|
e4de6bf4a7 | ||
|
|
21125c2f5a | ||
|
|
6f166425fe | ||
|
|
cf2353bcf9 | ||
|
|
744eb58071 | ||
|
|
9d47a6f41f | ||
|
|
4f4c23b12f | ||
|
|
fb02815c27 | ||
|
|
fd19299ae0 | ||
|
|
9c053e20da | ||
|
|
19d7b5c65d | ||
|
|
7b9d8829da | ||
|
|
3505ac498c | ||
|
|
f0ab52eb5d | ||
|
|
e8cebad27e | ||
|
|
6441d5838d | ||
|
|
ac0c8b1e9a | ||
|
|
8ec062fbef | ||
|
|
5990a100db | ||
|
|
bc35278684 | ||
|
|
c3c7329ebb | ||
|
|
6fd1c84126 | ||
|
|
0100f0fcc9 | ||
|
|
0cdc32cf65 | ||
|
|
601e9eebbd | ||
|
|
eaa868cf06 | ||
|
|
f55504c665 | ||
|
|
b2ff016cc1 | ||
|
|
33b4f17945 | ||
|
|
e310a3560b | ||
|
|
162b27323e | ||
|
|
ae976ef8d6 | ||
|
|
c6b4e2b71d | ||
|
|
33c8bbd0ce | ||
|
|
f2a3b8dba4 | ||
|
|
207ae6129b | ||
|
|
e1aa734c40 | ||
|
|
9b1b03bbfa | ||
|
|
bb7e0528c4 | ||
|
|
010eadcd10 | ||
|
|
c43e0b54f2 | ||
|
|
6522b74e20 | ||
|
|
8c7975d89a | ||
|
|
407070c9fc | ||
|
|
7821a3cd61 | ||
|
|
a00c2fcfdb | ||
|
|
9cd21d1326 | ||
|
|
aaba95f9b8 | ||
|
|
8d1135a2a3 | ||
|
|
f9fabbedce | ||
|
|
16012e6ffe | ||
|
|
d10a132b0c | ||
|
|
0b3af7d824 | ||
|
|
d0fdae3df7 | ||
|
|
a263611746 | ||
|
|
0e989419c6 | ||
|
|
0fa8276d2d | ||
|
|
b594986241 | ||
|
|
9f3ffa3707 | ||
|
|
8e598c19dc | ||
|
|
2601d6e906 | ||
|
|
de41088051 | ||
|
|
f2752b2a02 | ||
|
|
f0544fab89 | ||
|
|
1b9bf01ab1 | ||
|
|
9945367fa1 | ||
|
|
cbc3887226 | ||
|
|
c11b74e9c0 | ||
|
|
2b764c2abd | ||
|
|
845fc338d7 | ||
|
|
977243ebfd | ||
|
|
29ca544c95 | ||
|
|
94b41d3a2c | ||
|
|
92bb783cbb | ||
|
|
8348263fab | ||
|
|
48f633de11 | ||
|
|
b3b9a629f3 | ||
|
|
5934b7344a | ||
|
|
a9a2e40fed | ||
|
|
656326355a | ||
|
|
b89e2e5355 | ||
|
|
2d187abf13 | ||
|
|
b701412295 | ||
|
|
b4dad81220 | ||
|
|
6bccdad998 | ||
|
|
ecd6b0174a | ||
|
|
a1e534a515 | ||
|
|
ebbe19ba63 | ||
|
|
6a37b73463 | ||
|
|
dd18fcaea2 | ||
|
|
5afc058f90 | ||
|
|
5e221fa9a3 | ||
|
|
0e0cb4d422 | ||
|
|
09f6d60ae9 | ||
|
|
9577d552c6 | ||
|
|
093f17dce2 | ||
|
|
6089f49b9c | ||
|
|
cfb910e87e | ||
|
|
376cffc61d | ||
|
|
d338ba5152 | ||
|
|
f181397664 | ||
|
|
708f23a2ee | ||
|
|
2d1a979eba | ||
|
|
ee0be92967 | ||
|
|
7536b75508 | ||
|
|
7237ae6c54 | ||
|
|
ca05753a3e | ||
|
|
9ca8503eac | ||
|
|
754f71ce00 | ||
|
|
619b05e56c | ||
|
|
8b13826949 | ||
|
|
a96ee57c7e | ||
|
|
ff1ef90a6d | ||
|
|
22905fa8ee | ||
|
|
9e218ddd1c | ||
|
|
6f0462622b | ||
|
|
2f17161163 |
@@ -158,7 +158,7 @@ ij_java_keep_indents_on_empty_lines = false
|
||||
ij_java_keep_line_breaks = true
|
||||
ij_java_keep_multiple_expressions_in_one_line = false
|
||||
ij_java_keep_simple_blocks_in_one_line = false
|
||||
ij_java_keep_simple_classes_in_one_line = false
|
||||
ij_java_keep_simple_classes_in_one_line = true
|
||||
ij_java_keep_simple_lambdas_in_one_line = false
|
||||
ij_java_keep_simple_methods_in_one_line = false
|
||||
ij_java_label_indent_absolute = false
|
||||
|
||||
6
.github/workflows/documentation.yml
vendored
6
.github/workflows/documentation.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||
- uses: actions/setup-java@5ffc13f4174014e2d4d4572b3d74c3fa61aeb2c2 # v3.11.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version-file: .java-version
|
||||
cache: 'maven'
|
||||
- name: Compile and Build OpenAPI file
|
||||
run: ./mvnw compile
|
||||
|
||||
27
.github/workflows/integration-tests.yml
vendored
27
.github/workflows/integration-tests.yml
vendored
@@ -1,30 +1,37 @@
|
||||
name: Integration Tests
|
||||
|
||||
on: [workflow_dispatch]
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 19 * * MON-FRI'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# This may seem a little redundant, but copying the configuration to an environment variable makes it easier and safer
|
||||
# to then write its contents to a file
|
||||
INTEGRATION_TEST_CONFIG: ${{ vars.INTEGRATION_TEST_CONFIG }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ vars.INTEGRATION_TEST_CONFIG != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||
- uses: actions/setup-java@5ffc13f4174014e2d4d4572b3d74c3fa61aeb2c2 # v3.11.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version-file: .java-version
|
||||
cache: 'maven'
|
||||
- uses: aws-actions/configure-aws-credentials@v2
|
||||
- uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v5.0.0
|
||||
name: Configure AWS credentials from Test account
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Fetch integration utils library
|
||||
- name: Write integration test configuration
|
||||
run: |
|
||||
mkdir -p integration-tests/.libs
|
||||
mkdir -p integration-tests/src/main/resources
|
||||
wget -O integration-tests/.libs/software.amazon.awssdk-sso.jar https://repo1.maven.org/maven2/software/amazon/awssdk/sso/2.19.8/sso-2.19.8.jar
|
||||
aws s3 cp "s3://${{ vars.INTEGRATION_TESTS_BUCKET }}/config-latest.yml" integration-tests/src/main/resources/config.yml
|
||||
echo "${INTEGRATION_TEST_CONFIG}" > integration-tests/src/main/resources/config.yml
|
||||
- name: Run and verify integration tests
|
||||
run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify
|
||||
run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify -P aws-sso
|
||||
|
||||
53
.github/workflows/test.yml
vendored
53
.github/workflows/test.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Service CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- gh-pages
|
||||
@@ -8,19 +9,59 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container: ubuntu:22.04
|
||||
container: ubuntu:24.04
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
foundationdb0:
|
||||
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if
|
||||
# it's a little behind the CLIENT version.
|
||||
image: foundationdb/foundationdb:7.3.62
|
||||
options: --name foundationdb0
|
||||
foundationdb1:
|
||||
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if
|
||||
# it's a little behind the CLIENT version.
|
||||
image: foundationdb/foundationdb:7.3.62
|
||||
options: --name foundationdb1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@5ffc13f4174014e2d4d4572b3d74c3fa61aeb2c2 # v3.11.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
java-version-file: .java-version
|
||||
cache: 'maven'
|
||||
env:
|
||||
# work around an issue with actions/runner setting an incorrect HOME in containers, which breaks maven caching
|
||||
# https://github.com/actions/setup-java/issues/356
|
||||
HOME: /root
|
||||
- name: Install APT packages
|
||||
# ca-certificates: required for AWS CRT client
|
||||
run: |
|
||||
# Add Docker's official GPG key:
|
||||
apt update
|
||||
apt install -y ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
# Add Docker repository to apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# ca-certificates: required for AWS CRT client
|
||||
apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ca-certificates
|
||||
- name: Configure FoundationDB0 database
|
||||
run: docker exec foundationdb0 /usr/bin/fdbcli --exec 'configure new single memory'
|
||||
- name: Configure FoundationDB1 database
|
||||
run: docker exec foundationdb1 /usr/bin/fdbcli --exec 'configure new single memory'
|
||||
- name: Download and install FoundationDB client
|
||||
run: |
|
||||
./mvnw -e -B -Pexclude-spam-filter clean prepare-package -DskipTests=true
|
||||
cp service/target/jib-extra/usr/lib/libfdb_c.x86_64.so /usr/lib/libfdb_c.x86_64.so
|
||||
ldconfig
|
||||
- name: Build with Maven
|
||||
run: ./mvnw -e -B verify
|
||||
run: ./mvnw -e -B clean verify -DfoundationDb.serviceContainerNamePrefix=foundationdb
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,7 +16,6 @@ config/deploy.properties
|
||||
/service/config/testing.yml
|
||||
/service/config/deploy.properties
|
||||
/service/dependency-reduced-pom.xml
|
||||
.java-version
|
||||
.opsmanage
|
||||
put.sh
|
||||
deployer-staging.properties
|
||||
@@ -29,3 +28,4 @@ deployer.log
|
||||
.project
|
||||
.classpath
|
||||
.settings
|
||||
.DS_Store
|
||||
|
||||
1
.java-version
Normal file
1
.java-version
Normal file
@@ -0,0 +1 @@
|
||||
temurin-24
|
||||
@@ -4,6 +4,6 @@
|
||||
<extension>
|
||||
<groupId>fr.brouillard.oss</groupId>
|
||||
<artifactId>jgitver-maven-plugin</artifactId>
|
||||
<version>1.7.1</version>
|
||||
<version>1.9.0</version>
|
||||
</extension>
|
||||
</extensions>
|
||||
|
||||
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
Binary file not shown.
6
.mvn/wrapper/maven-wrapper.properties
vendored
6
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -14,5 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar
|
||||
distributionSha256Sum=0d7125e8c91097b36edb990ea5934e6c68b4440eef4ea96510a0f6815e7eeadb
|
||||
wrapperSha256Sum=4e2fbf6554bc8a4702cdfdd3bef464f423393d784ddbb037216320ce55d5e4e1
|
||||
|
||||
29
README.md
29
README.md
@@ -8,9 +8,28 @@ Looking for protocol documentation? Check out the website!
|
||||
|
||||
https://signal.org/docs/
|
||||
|
||||
Cryptography Notice
|
||||
How to Build
|
||||
------------
|
||||
|
||||
This project uses [FoundationDB](https://www.foundationdb.org/) and requires the FoundationDB client library to be installed on the host system. With that in place, the server can be built and tested with:
|
||||
|
||||
```shell script
|
||||
$ ./mvnw clean test
|
||||
```
|
||||
|
||||
Security
|
||||
--------
|
||||
|
||||
Security issues should be sent to <a href=mailto:security@signal.org>security@signal.org</a>.
|
||||
|
||||
Help
|
||||
----
|
||||
|
||||
We cannot provide direct technical support. Get help running this software in your own environment in our [unofficial community forum][community forum].
|
||||
|
||||
Cryptography Notice
|
||||
-------------------
|
||||
|
||||
This distribution includes cryptographic software. The country in which you currently reside may have restrictions on the import, possession, use, and/or re-export to another country, of encryption software.
|
||||
BEFORE using any encryption software, please check your country's laws, regulations and policies concerning the import, possession, or use, and re-export of encryption software, to see if this is permitted.
|
||||
See <https://www.wassenaar.org/> for more information.
|
||||
@@ -19,8 +38,10 @@ The U.S. Government Department of Commerce, Bureau of Industry and Security (BIS
|
||||
The form and manner of this distribution makes it eligible for export under the License Exception ENC Technology Software Unrestricted (TSU) exception (see the BIS Export Administration Regulations, Section 740.13) for both object code and source code.
|
||||
|
||||
License
|
||||
---------------------
|
||||
-------
|
||||
|
||||
Copyright 2013-2023 Signal Messenger, LLC
|
||||
Copyright 2013 Signal Messenger, LLC
|
||||
|
||||
Licensed under the AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
[community forum]: https://community.signalusers.org
|
||||
|
||||
30
TESTING.md
Normal file
30
TESTING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Testing
|
||||
|
||||
## Automated tests
|
||||
|
||||
The full suite of automated tests can be run using Maven from the project root:
|
||||
|
||||
```sh
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
## Test server
|
||||
|
||||
The service can be run in a feature-limited test mode by running the Maven `integration-test`
|
||||
goal with the `test-server` profile activated:
|
||||
|
||||
```sh
|
||||
./mvnw integration-test -Ptest-server [-DskipTests=true]
|
||||
```
|
||||
|
||||
This runs [`LocalWhisperServerService`][lwss] with [test configuration][test.yml] and [secrets][test secrets]. External
|
||||
registration clients are stubbed so that:
|
||||
|
||||
- a captcha requirement can be satisfied with `noop.noop.registration.noop`
|
||||
- any string will be accepted for a phone verification code
|
||||
|
||||
[lwss]: service/src/test/java/org/whispersystems/textsecuregcm/LocalWhisperServerService.java
|
||||
|
||||
[test.yml]: service/src/test/resources/config/test.yml
|
||||
|
||||
[test secrets]: service/src/test/resources/config/test-secrets-bundle.yml
|
||||
@@ -22,8 +22,8 @@
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-maven-plugin</artifactId>
|
||||
<version>2.2.8</version>
|
||||
<artifactId>swagger-maven-plugin-jakarta</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<configuration>
|
||||
<outputFileName>signal-server-openapi</outputFileName>
|
||||
<outputPath>${project.build.directory}/openapi</outputPath>
|
||||
|
||||
@@ -13,6 +13,7 @@ import io.swagger.v3.jaxrs2.ResolvedParameter;
|
||||
import io.swagger.v3.jaxrs2.ext.AbstractOpenAPIExtension;
|
||||
import io.swagger.v3.jaxrs2.ext.OpenAPIExtension;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Iterator;
|
||||
@@ -20,9 +21,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
import javax.ws.rs.Consumes;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
|
||||
/**
|
||||
* One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations
|
||||
@@ -42,10 +41,6 @@ public class OpenApiExtension extends AbstractOpenAPIExtension {
|
||||
|
||||
public static final ResolvedParameter OPTIONAL_AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
public static final ResolvedParameter DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
public static final ResolvedParameter OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
/**
|
||||
* When parsing endpoint methods, Swagger will treat the first parameter not annotated as header/path/query param
|
||||
* as a request body (and will ignore other not annotated parameters). In our case, this behavior conflicts with
|
||||
@@ -67,21 +62,13 @@ public class OpenApiExtension extends AbstractOpenAPIExtension {
|
||||
if (annotations.stream().anyMatch(a -> a.annotationType().equals(Auth.class))) {
|
||||
// this is the case of authenticated endpoint,
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& simpleType.getRawClass().equals(AuthenticatedAccount.class)) {
|
||||
&& simpleType.getRawClass().equals(AuthenticatedDevice.class)) {
|
||||
return AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& simpleType.getRawClass().equals(DisabledPermittedAuthenticatedAccount.class)) {
|
||||
return DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& isOptionalOfType(simpleType, AuthenticatedAccount.class)) {
|
||||
&& isOptionalOfType(simpleType, AuthenticatedDevice.class)) {
|
||||
return OPTIONAL_AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& isOptionalOfType(simpleType, DisabledPermittedAuthenticatedAccount.class)) {
|
||||
return OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
}
|
||||
|
||||
return super.extractParameters(
|
||||
|
||||
@@ -7,9 +7,7 @@ package org.signal.openapi;
|
||||
|
||||
import static com.google.common.base.MoreObjects.firstNonNull;
|
||||
import static org.signal.openapi.OpenApiExtension.AUTHENTICATED_ACCOUNT;
|
||||
import static org.signal.openapi.OpenApiExtension.DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
import static org.signal.openapi.OpenApiExtension.OPTIONAL_AUTHENTICATED_ACCOUNT;
|
||||
import static org.signal.openapi.OpenApiExtension.OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonView;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
@@ -17,11 +15,11 @@ import io.swagger.v3.jaxrs2.Reader;
|
||||
import io.swagger.v3.jaxrs2.ResolvedParameter;
|
||||
import io.swagger.v3.oas.models.Operation;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import javax.ws.rs.Consumes;
|
||||
|
||||
/**
|
||||
* One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations
|
||||
@@ -54,13 +52,13 @@ public class OpenApiReader extends Reader {
|
||||
final ResolvedParameter resolved = super.getParameters(
|
||||
type, annotations, operation, classConsumes, methodConsumes, jsonViewAnnotation);
|
||||
|
||||
if (resolved == AUTHENTICATED_ACCOUNT || resolved == DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT) {
|
||||
if (resolved == AUTHENTICATED_ACCOUNT) {
|
||||
operation.setSecurity(ImmutableList.<SecurityRequirement>builder()
|
||||
.addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))
|
||||
.add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))
|
||||
.build());
|
||||
}
|
||||
if (resolved == OPTIONAL_AUTHENTICATED_ACCOUNT || resolved == OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT) {
|
||||
if (resolved == OPTIONAL_AUTHENTICATED_ACCOUNT) {
|
||||
operation.setSecurity(ImmutableList.<SecurityRequirement>builder()
|
||||
.addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))
|
||||
.add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2022 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>TextSecureServer</artifactId>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<version>JGITVER</version>
|
||||
</parent>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>event-logger</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-logging</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<!--
|
||||
depends on an outdated version (13.0) for JDK 6 compatibility, but it’s safe to override
|
||||
https://youtrack.jetbrains.com/issue/KT-25047
|
||||
-->
|
||||
<artifactId>annotations</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
<artifactId>kotlinx-serialization-json</artifactId>
|
||||
<version>${kotlinx-serialization.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
|
||||
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<compilerPlugins>
|
||||
<plugin>kotlinx-serialization</plugin>
|
||||
</compilerPlugins>
|
||||
</configuration>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-serialization</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.google.cloud.tools</groupId>
|
||||
<artifactId>jib-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- we don't want jib to execute on this module -->
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.event
|
||||
|
||||
import java.util.Collections
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
val module = SerializersModule {
|
||||
polymorphic(Event::class) {
|
||||
subclass(RemoteConfigSetEvent::class)
|
||||
subclass(RemoteConfigDeleteEvent::class)
|
||||
}
|
||||
}
|
||||
val jsonFormat = Json { serializersModule = module }
|
||||
|
||||
sealed interface Event
|
||||
|
||||
@Serializable
|
||||
data class RemoteConfigSetEvent(
|
||||
val identity: String,
|
||||
val name: String,
|
||||
val percentage: Int,
|
||||
val defaultValue: String? = null,
|
||||
val value: String? = null,
|
||||
val hashKey: String? = null,
|
||||
val uuids: Collection<String> = Collections.emptyList(),
|
||||
) : Event
|
||||
|
||||
@Serializable
|
||||
data class RemoteConfigDeleteEvent(
|
||||
val identity: String,
|
||||
val name: String,
|
||||
) : Event
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.event
|
||||
|
||||
import com.google.cloud.logging.LogEntry
|
||||
import com.google.cloud.logging.Logging
|
||||
import com.google.cloud.logging.MonitoredResourceUtil
|
||||
import com.google.cloud.logging.Payload.JsonPayload
|
||||
import com.google.cloud.logging.Severity
|
||||
import com.google.protobuf.Struct
|
||||
import com.google.protobuf.util.JsonFormat
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
interface AdminEventLogger {
|
||||
fun logEvent(event: Event, labels: Map<String, String>?)
|
||||
fun logEvent(event: Event) = logEvent(event, null)
|
||||
}
|
||||
|
||||
class NoOpAdminEventLogger : AdminEventLogger {
|
||||
override fun logEvent(event: Event, labels: Map<String, String>?) {}
|
||||
}
|
||||
|
||||
class GoogleCloudAdminEventLogger(private val logging: Logging, private val projectId: String, private val logName: String) : AdminEventLogger {
|
||||
override fun logEvent(event: Event, labels: Map<String, String>?) {
|
||||
val structBuilder = Struct.newBuilder()
|
||||
JsonFormat.parser().merge(jsonFormat.encodeToString(event), structBuilder)
|
||||
val struct = structBuilder.build()
|
||||
|
||||
val logEntryBuilder = LogEntry.newBuilder(JsonPayload.of(struct))
|
||||
.setLogName(logName)
|
||||
.setSeverity(Severity.NOTICE)
|
||||
.setResource(MonitoredResourceUtil.getResource(projectId, "project"));
|
||||
if (labels != null) {
|
||||
logEntryBuilder.setLabels(labels);
|
||||
}
|
||||
logging.write(listOf(logEntryBuilder.build()))
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.event
|
||||
|
||||
import com.google.cloud.logging.Logging
|
||||
import com.google.cloud.logging.LoggingOptions
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertDoesNotThrow
|
||||
import org.mockito.Mockito.mock
|
||||
|
||||
class GoogleCloudAdminEventLoggerTest {
|
||||
|
||||
@Test
|
||||
fun logEvent() {
|
||||
val logging = mock(Logging::class.java)
|
||||
val logger = GoogleCloudAdminEventLogger(logging, "my-project", "test")
|
||||
|
||||
val event = RemoteConfigDeleteEvent("token", "test")
|
||||
logger.logEvent(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetService() {
|
||||
assertDoesNotThrow {
|
||||
// This is a canary for version conflicts between the cloud logging library and protobuf-java
|
||||
LoggingOptions.newBuilder()
|
||||
.setProjectId("test")
|
||||
.build()
|
||||
.getService()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M7</version>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**</exclude>
|
||||
@@ -39,7 +38,6 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<configuration>
|
||||
<additionalClasspathElements>
|
||||
<additionalClasspathElement>${project.basedir}/.libs/software.amazon.awssdk-sso.jar</additionalClasspathElement>
|
||||
@@ -59,4 +57,17 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>aws-sso</id>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>sso</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
|
||||
public final class Codecs {
|
||||
@@ -84,7 +83,7 @@ public final class Codecs {
|
||||
|
||||
public static class ECPublicKeyDeserializer extends Base64BasedDeserializer<ECPublicKey> {
|
||||
public ECPublicKeyDeserializer() {
|
||||
super(bytes -> Curve.decodePoint(bytes, 0));
|
||||
super(ECPublicKey::new);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,19 @@ package org.signal.integration;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.signal.integration.config.Config;
|
||||
import org.whispersystems.textsecuregcm.metrics.NoopAwsSdkMetricPublisher;
|
||||
import org.whispersystems.textsecuregcm.registration.VerificationSession;
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
|
||||
public class IntegrationTools {
|
||||
|
||||
@@ -27,39 +28,41 @@ public class IntegrationTools {
|
||||
|
||||
private final VerificationSessionManager verificationSessionManager;
|
||||
|
||||
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
|
||||
|
||||
|
||||
public static IntegrationTools create(final Config config) {
|
||||
final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
|
||||
|
||||
final DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
||||
config.dynamoDbClientConfiguration(),
|
||||
credentialsProvider);
|
||||
|
||||
final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(
|
||||
config.dynamoDbClientConfiguration(),
|
||||
credentialsProvider);
|
||||
final DynamoDbAsyncClient dynamoDbAsyncClient =
|
||||
config.dynamoDbClient().buildAsyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());
|
||||
|
||||
final RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
|
||||
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbClient, dynamoDbAsyncClient);
|
||||
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbAsyncClient, Clock.systemUTC());
|
||||
|
||||
final VerificationSessions verificationSessions = new VerificationSessions(
|
||||
dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
|
||||
|
||||
return new IntegrationTools(
|
||||
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),
|
||||
new VerificationSessionManager(verificationSessions)
|
||||
new VerificationSessionManager(verificationSessions),
|
||||
new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers())
|
||||
);
|
||||
}
|
||||
|
||||
private IntegrationTools(
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
final VerificationSessionManager verificationSessionManager) {
|
||||
final VerificationSessionManager verificationSessionManager,
|
||||
final PhoneNumberIdentifiers phoneNumberIdentifiers) {
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.verificationSessionManager = verificationSessionManager;
|
||||
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> populateRecoveryPassword(final String e164, final byte[] password) {
|
||||
return registrationRecoveryPasswordsManager.storeForCurrentNumber(e164, password);
|
||||
public CompletableFuture<Void> populateRecoveryPassword(final String phoneNumber, final byte[] password) {
|
||||
return phoneNumberIdentifiers
|
||||
.getPhoneNumberIdentifier(phoneNumber)
|
||||
.thenCompose(pni -> registrationRecoveryPasswordsManager.store(pni, password));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<String>> peekVerificationSessionPushChallenge(final String sessionId) {
|
||||
|
||||
@@ -10,6 +10,9 @@ import static java.util.Objects.requireNonNull;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.google.common.io.Resources;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.configuration.ConfigurationValidationException;
|
||||
import io.dropwizard.jersey.validation.Validators;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import java.io.IOException;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.net.URI;
|
||||
@@ -17,20 +20,20 @@ import java.net.URL;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.apache.commons.lang3.RandomUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.signal.integration.config.Config;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyPair;
|
||||
@@ -38,15 +41,16 @@ import org.signal.libsignal.protocol.kem.KEMKeyType;
|
||||
import org.signal.libsignal.protocol.kem.KEMPublicKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
|
||||
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HttpUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public final class Operations {
|
||||
@@ -67,61 +71,28 @@ public final class Operations {
|
||||
}
|
||||
|
||||
public static TestUser newRegisteredUser(final String number) {
|
||||
final byte[] registrationPassword = RandomUtils.nextBytes(32);
|
||||
final String accountPassword = Base64.getEncoder().encodeToString(RandomUtils.nextBytes(32));
|
||||
final byte[] registrationPassword = populateRandomRecoveryPassword(number);
|
||||
final String accountPassword = Base64.getEncoder().encodeToString(randomBytes(32));
|
||||
|
||||
final TestUser user = TestUser.create(number, accountPassword, registrationPassword);
|
||||
final AccountAttributes accountAttributes = user.accountAttributes();
|
||||
|
||||
INTEGRATION_TOOLS.populateRecoveryPassword(number, registrationPassword).join();
|
||||
|
||||
// register account
|
||||
final RegistrationRequest registrationRequest = new RegistrationRequest(
|
||||
null, registrationPassword, accountAttributes, true, false,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
|
||||
|
||||
final AccountIdentityResponse registrationResponse = apiPost("/v1/registration", registrationRequest)
|
||||
.authorized(number, accountPassword)
|
||||
.executeExpectSuccess(AccountIdentityResponse.class);
|
||||
|
||||
user.setAciUuid(registrationResponse.uuid());
|
||||
user.setPniUuid(registrationResponse.pni());
|
||||
|
||||
// upload pre-key
|
||||
final TestUser.PreKeySetPublicView preKeySetPublicView = user.preKeys(Device.MASTER_ID, false);
|
||||
apiPut("/v2/keys", preKeySetPublicView)
|
||||
.authorized(user, Device.MASTER_ID)
|
||||
.executeExpectSuccess();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public static TestUser newRegisteredUserAtomic(final String number) {
|
||||
final byte[] registrationPassword = RandomUtils.nextBytes(32);
|
||||
final String accountPassword = Base64.getEncoder().encodeToString(RandomUtils.nextBytes(32));
|
||||
|
||||
final TestUser user = TestUser.create(number, accountPassword, registrationPassword);
|
||||
final AccountAttributes accountAttributes = user.accountAttributes();
|
||||
|
||||
INTEGRATION_TOOLS.populateRecoveryPassword(number, registrationPassword).join();
|
||||
|
||||
final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair();
|
||||
final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair();
|
||||
final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();
|
||||
final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();
|
||||
|
||||
// register account
|
||||
final RegistrationRequest registrationRequest = new RegistrationRequest(null,
|
||||
registrationPassword,
|
||||
accountAttributes,
|
||||
true,
|
||||
true,
|
||||
Optional.of(new IdentityKey(aciIdentityKeyPair.getPublicKey())),
|
||||
Optional.of(new IdentityKey(pniIdentityKeyPair.getPublicKey())),
|
||||
Optional.of(generateSignedECPreKey(1, aciIdentityKeyPair)),
|
||||
Optional.of(generateSignedECPreKey(2, pniIdentityKeyPair)),
|
||||
Optional.of(generateSignedKEMPreKey(3, aciIdentityKeyPair)),
|
||||
Optional.of(generateSignedKEMPreKey(4, pniIdentityKeyPair)),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
new IdentityKey(aciIdentityKeyPair.getPublicKey()),
|
||||
new IdentityKey(pniIdentityKeyPair.getPublicKey()),
|
||||
new DeviceActivationRequest(generateSignedECPreKey(1, aciIdentityKeyPair),
|
||||
generateSignedECPreKey(2, pniIdentityKeyPair),
|
||||
generateSignedKEMPreKey(3, aciIdentityKeyPair),
|
||||
generateSignedKEMPreKey(4, pniIdentityKeyPair),
|
||||
Optional.empty(),
|
||||
Optional.empty()));
|
||||
|
||||
final AccountIdentityResponse registrationResponse = apiPost("/v1/registration", registrationRequest)
|
||||
.authorized(number, accountPassword)
|
||||
@@ -133,6 +104,14 @@ public final class Operations {
|
||||
return user;
|
||||
}
|
||||
|
||||
public record PrescribedVerificationNumber(String number, String verificationCode) {}
|
||||
|
||||
public static PrescribedVerificationNumber prescribedVerificationNumber() {
|
||||
return new PrescribedVerificationNumber(
|
||||
CONFIG.prescribedRegistrationNumber(),
|
||||
CONFIG.prescribedRegistrationCode());
|
||||
}
|
||||
|
||||
public static void deleteUser(final TestUser user) {
|
||||
apiDelete("/v1/accounts/me").authorized(user).executeExpectSuccess();
|
||||
}
|
||||
@@ -142,6 +121,13 @@ public final class Operations {
|
||||
.orElseThrow(() -> new RuntimeException("push challenge not found for the verification session"));
|
||||
}
|
||||
|
||||
public static byte[] populateRandomRecoveryPassword(final String number) {
|
||||
final byte[] recoveryPassword = randomBytes(32);
|
||||
INTEGRATION_TOOLS.populateRecoveryPassword(number, recoveryPassword).join();
|
||||
|
||||
return recoveryPassword;
|
||||
}
|
||||
|
||||
public static <T> T sendEmptyRequestAuthenticated(
|
||||
final String endpoint,
|
||||
final String method,
|
||||
@@ -180,6 +166,12 @@ public final class Operations {
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int numBytes) {
|
||||
final byte[] bytes = new byte[numBytes];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static RequestBuilder apiGet(final String endpoint) {
|
||||
return new RequestBuilder(HttpRequest.newBuilder().GET(), endpoint);
|
||||
}
|
||||
@@ -233,10 +225,10 @@ public final class Operations {
|
||||
}
|
||||
|
||||
public RequestBuilder authorized(final TestUser user) {
|
||||
return authorized(user, Device.MASTER_ID);
|
||||
return authorized(user, Device.PRIMARY_ID);
|
||||
}
|
||||
|
||||
public RequestBuilder authorized(final TestUser user, final long deviceId) {
|
||||
public RequestBuilder authorized(final TestUser user, final byte deviceId) {
|
||||
final String username = "%s.%d".formatted(user.aciUuid().toString(), deviceId);
|
||||
return authorized(username, user.accountPassword());
|
||||
}
|
||||
@@ -263,7 +255,7 @@ public final class Operations {
|
||||
public Pair<Integer, Void> executeExpectSuccess() {
|
||||
final Pair<Integer, Void> execute = execute();
|
||||
Validate.isTrue(
|
||||
execute.getLeft() >= 200 && execute.getLeft() < 300,
|
||||
HttpUtils.isSuccessfulResponse(execute.getLeft()),
|
||||
"Unexpected response code: %d",
|
||||
execute.getLeft());
|
||||
return execute;
|
||||
@@ -271,6 +263,10 @@ public final class Operations {
|
||||
|
||||
public <T> T executeExpectSuccess(final Class<T> expectedType) {
|
||||
final Pair<Integer, T> execute = execute(expectedType);
|
||||
Validate.isTrue(
|
||||
HttpUtils.isSuccessfulResponse(execute.getLeft()),
|
||||
"Unexpected response code: %d : %s",
|
||||
execute.getLeft(), execute.getRight());
|
||||
return requireNonNull(execute.getRight());
|
||||
}
|
||||
|
||||
@@ -309,11 +305,7 @@ public final class Operations {
|
||||
|
||||
private static FaultTolerantHttpClient buildClient() {
|
||||
try {
|
||||
return FaultTolerantHttpClient.newBuilder()
|
||||
.withName("integration-test")
|
||||
.withExecutor(Executors.newFixedThreadPool(16))
|
||||
.withRetryExecutor(Executors.newSingleThreadScheduledExecutor())
|
||||
.withCircuitBreaker(new CircuitBreakerConfiguration())
|
||||
return FaultTolerantHttpClient.newBuilder("integration-test", Executors.newFixedThreadPool(16))
|
||||
.withTrustedServerCertificates(CONFIG.rootCert())
|
||||
.build();
|
||||
} catch (final CertificateException e) {
|
||||
@@ -324,21 +316,29 @@ public final class Operations {
|
||||
private static Config loadConfigFromClasspath(final String filename) {
|
||||
try {
|
||||
final URL configFileUrl = Resources.getResource(filename);
|
||||
return SystemMapper.yamlMapper().readValue(Resources.toByteArray(configFileUrl), Config.class);
|
||||
} catch (final IOException e) {
|
||||
final Config config = SystemMapper.yamlMapper().readValue(Resources.toByteArray(configFileUrl), Config.class);
|
||||
|
||||
final Set<ConstraintViolation<Config>> constraintViolations = Validators.newValidator().validate(config);
|
||||
|
||||
if (!constraintViolations.isEmpty()) {
|
||||
throw new ConfigurationValidationException(filename, constraintViolations);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (final Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static ECSignedPreKey generateSignedECPreKey(long id, final ECKeyPair identityKeyPair) {
|
||||
final ECPublicKey pubKey = Curve.generateKeyPair().getPublicKey();
|
||||
final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new ECSignedPreKey(id, pubKey, sig);
|
||||
public static ECSignedPreKey generateSignedECPreKey(final long id, final ECKeyPair identityKeyPair) {
|
||||
final ECPublicKey pubKey = ECKeyPair.generate().getPublicKey();
|
||||
final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new ECSignedPreKey(id, pubKey, signature);
|
||||
}
|
||||
|
||||
private static KEMSignedPreKey generateSignedKEMPreKey(long id, final ECKeyPair identityKeyPair) {
|
||||
public static KEMSignedPreKey generateSignedKEMPreKey(final long id, final ECKeyPair identityKeyPair) {
|
||||
final KEMPublicKey pubKey = KEMKeyPair.generate(KEMKeyType.KYBER_1024).getPublicKey();
|
||||
final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new KEMSignedPreKey(id, pubKey, sig);
|
||||
final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new KEMSignedPreKey(id, pubKey, signature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,18 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
|
||||
public class TestDevice {
|
||||
|
||||
private final long deviceId;
|
||||
private final byte deviceId;
|
||||
|
||||
private final Map<Integer, Pair<IdentityKeyPair, SignedPreKeyRecord>> signedPreKeys = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
public static TestDevice create(
|
||||
final long deviceId,
|
||||
final byte deviceId,
|
||||
final IdentityKeyPair aciIdentityKeyPair,
|
||||
final IdentityKeyPair pniIdentityKeyPair) {
|
||||
final TestDevice device = new TestDevice(deviceId);
|
||||
@@ -31,11 +29,11 @@ public class TestDevice {
|
||||
return device;
|
||||
}
|
||||
|
||||
public TestDevice(final long deviceId) {
|
||||
public TestDevice(final byte deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
public long deviceId() {
|
||||
public byte deviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
@@ -50,15 +48,11 @@ public class TestDevice {
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord addSignedPreKey(final IdentityKeyPair identity) {
|
||||
try {
|
||||
final int nextId = signedPreKeys.keySet().stream().mapToInt(k -> k + 1).max().orElse(0);
|
||||
final ECKeyPair keyPair = Curve.generateKeyPair();
|
||||
final byte[] signature = Curve.calculateSignature(identity.getPrivateKey(), keyPair.getPublicKey().serialize());
|
||||
final SignedPreKeyRecord signedPreKeyRecord = new SignedPreKeyRecord(nextId, System.currentTimeMillis(), keyPair, signature);
|
||||
signedPreKeys.put(nextId, Pair.of(identity, signedPreKeyRecord));
|
||||
return signedPreKeyRecord;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
final int nextId = signedPreKeys.keySet().stream().mapToInt(k -> k + 1).max().orElse(0);
|
||||
final ECKeyPair keyPair = ECKeyPair.generate();
|
||||
final byte[] signature = keyPair.getPrivateKey().calculateSignature(keyPair.getPublicKey().serialize());
|
||||
final SignedPreKeyRecord signedPreKeyRecord = new SignedPreKeyRecord(nextId, System.currentTimeMillis(), keyPair, signature);
|
||||
signedPreKeys.put(nextId, Pair.of(identity, signedPreKeyRecord));
|
||||
return signedPreKeyRecord;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,21 @@ import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import java.security.SecureRandom;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.apache.commons.lang3.RandomUtils;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.util.KeyHelper;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
@@ -27,9 +31,11 @@ public class TestUser {
|
||||
|
||||
private final int registrationId;
|
||||
|
||||
private final int pniRegistrationId;
|
||||
|
||||
private final IdentityKeyPair aciIdentityKey;
|
||||
|
||||
private final Map<Long, TestDevice> devices = new ConcurrentHashMap<>();
|
||||
private final Map<Byte, TestDevice> devices = new ConcurrentHashMap<>();
|
||||
|
||||
private final byte[] unidentifiedAccessKey;
|
||||
|
||||
@@ -53,11 +59,14 @@ public class TestUser {
|
||||
final IdentityKeyPair pniIdentityKey = IdentityKeyPair.generate();
|
||||
// registration id
|
||||
final int registrationId = KeyHelper.generateRegistrationId(false);
|
||||
final int pniRegistrationId = KeyHelper.generateRegistrationId(false);
|
||||
// uak
|
||||
final byte[] unidentifiedAccessKey = RandomUtils.nextBytes(16);
|
||||
final byte[] unidentifiedAccessKey = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH];
|
||||
new SecureRandom().nextBytes(unidentifiedAccessKey);
|
||||
|
||||
return new TestUser(
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
aciIdentityKey,
|
||||
phoneNumber,
|
||||
pniIdentityKey,
|
||||
@@ -68,6 +77,7 @@ public class TestUser {
|
||||
|
||||
public TestUser(
|
||||
final int registrationId,
|
||||
final int pniRegistrationId,
|
||||
final IdentityKeyPair aciIdentityKey,
|
||||
final String phoneNumber,
|
||||
final IdentityKeyPair pniIdentityKey,
|
||||
@@ -75,13 +85,14 @@ public class TestUser {
|
||||
final String accountPassword,
|
||||
final byte[] registrationPassword) {
|
||||
this.registrationId = registrationId;
|
||||
this.pniRegistrationId = pniRegistrationId;
|
||||
this.aciIdentityKey = aciIdentityKey;
|
||||
this.phoneNumber = phoneNumber;
|
||||
this.pniIdentityKey = pniIdentityKey;
|
||||
this.unidentifiedAccessKey = unidentifiedAccessKey;
|
||||
this.accountPassword = accountPassword;
|
||||
this.registrationPassword = registrationPassword;
|
||||
devices.put(Device.MASTER_ID, TestDevice.create(Device.MASTER_ID, aciIdentityKey, pniIdentityKey));
|
||||
devices.put(Device.PRIMARY_ID, TestDevice.create(Device.PRIMARY_ID, aciIdentityKey, pniIdentityKey));
|
||||
}
|
||||
|
||||
public int registrationId() {
|
||||
@@ -117,7 +128,7 @@ public class TestUser {
|
||||
}
|
||||
|
||||
public AccountAttributes accountAttributes() {
|
||||
return new AccountAttributes(true, registrationId, "", "", true, new Device.DeviceCapabilities(false, false, false, false))
|
||||
return new AccountAttributes(true, registrationId, pniRegistrationId, "".getBytes(StandardCharsets.UTF_8), "", true, Set.of())
|
||||
.withUnidentifiedAccessKey(unidentifiedAccessKey)
|
||||
.withRecoveryPassword(registrationPassword);
|
||||
}
|
||||
@@ -146,21 +157,25 @@ public class TestUser {
|
||||
this.registrationPassword = registrationPassword;
|
||||
}
|
||||
|
||||
public PreKeySetPublicView preKeys(final long deviceId, final boolean pni) {
|
||||
public PreKeySetPublicView preKeys(final byte deviceId, final boolean pni) {
|
||||
final IdentityKeyPair identity = pni
|
||||
? pniIdentityKey
|
||||
: aciIdentityKey;
|
||||
final TestDevice device = requireNonNull(devices.get(deviceId));
|
||||
final SignedPreKeyRecord signedPreKeyRecord = device.latestSignedPreKey(identity);
|
||||
return new PreKeySetPublicView(
|
||||
Collections.emptyList(),
|
||||
identity.getPublicKey(),
|
||||
new SignedPreKeyPublicView(
|
||||
signedPreKeyRecord.getId(),
|
||||
signedPreKeyRecord.getKeyPair().getPublicKey(),
|
||||
signedPreKeyRecord.getSignature()
|
||||
)
|
||||
);
|
||||
try {
|
||||
return new PreKeySetPublicView(
|
||||
Collections.emptyList(),
|
||||
identity.getPublicKey(),
|
||||
new SignedPreKeyPublicView(
|
||||
signedPreKeyRecord.getId(),
|
||||
signedPreKeyRecord.getKeyPair().getPublicKey(),
|
||||
signedPreKeyRecord.getSignature()
|
||||
)
|
||||
);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public record SignedPreKeyPublicView(
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
|
||||
package org.signal.integration.config;
|
||||
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;
|
||||
|
||||
public record Config(String domain,
|
||||
String rootCert,
|
||||
DynamoDbClientConfiguration dynamoDbClientConfiguration,
|
||||
DynamoDbTables dynamoDbTables) {
|
||||
public record Config(@NotBlank String domain,
|
||||
@NotBlank String rootCert,
|
||||
@NotNull @Valid DynamoDbClientFactory dynamoDbClient,
|
||||
@NotNull @Valid DynamoDbTables dynamoDbTables,
|
||||
@NotBlank String prescribedRegistrationNumber,
|
||||
@NotBlank String prescribedRegistrationCode) {
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
package org.signal.integration.config;
|
||||
|
||||
public record DynamoDbTables(String registrationRecovery,
|
||||
String verificationSessions) {
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record DynamoDbTables(@NotBlank String registrationRecovery,
|
||||
@NotBlank String verificationSessions,
|
||||
@NotBlank String phoneNumberIdentifiers) {
|
||||
}
|
||||
|
||||
@@ -6,27 +6,34 @@
|
||||
package org.signal.integration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.EncryptedUsername;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class AccountTest {
|
||||
|
||||
@Test
|
||||
public void testCreateAccount() throws Exception {
|
||||
public void testCreateAccount() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550101");
|
||||
try {
|
||||
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
|
||||
@@ -39,8 +46,8 @@ public class AccountTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateAccountAtomic() throws Exception {
|
||||
final TestUser user = Operations.newRegisteredUserAtomic("+19995550201");
|
||||
public void testCreateAccountAtomic() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550201");
|
||||
try {
|
||||
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
|
||||
.authorized(user)
|
||||
@@ -51,6 +58,33 @@ public class AccountTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changePhoneNumber() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550301");
|
||||
final String targetNumber = "+19995550302";
|
||||
|
||||
final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();
|
||||
|
||||
final ChangeNumberRequest changeNumberRequest = new ChangeNumberRequest(null,
|
||||
Operations.populateRandomRecoveryPassword(targetNumber),
|
||||
targetNumber,
|
||||
null,
|
||||
new IdentityKey(pniIdentityKeyPair.getPublicKey()),
|
||||
Collections.emptyList(),
|
||||
Map.of(Device.PRIMARY_ID, Operations.generateSignedECPreKey(1, pniIdentityKeyPair)),
|
||||
Map.of(Device.PRIMARY_ID, Operations.generateSignedKEMPreKey(2, pniIdentityKeyPair)),
|
||||
Map.of(Device.PRIMARY_ID, 17));
|
||||
|
||||
final AccountIdentityResponse accountIdentityResponse =
|
||||
Operations.apiPut("/v2/accounts/number", changeNumberRequest)
|
||||
.authorized(user)
|
||||
.executeExpectSuccess(AccountIdentityResponse.class);
|
||||
|
||||
assertEquals(user.aciUuid(), accountIdentityResponse.uuid());
|
||||
assertNotEquals(user.pniUuid(), accountIdentityResponse.pni());
|
||||
assertEquals(targetNumber, accountIdentityResponse.number());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUsernameOperations() throws Exception {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550102");
|
||||
@@ -107,7 +141,7 @@ public class AccountTest {
|
||||
final AccountIdentifierResponse accountIdentifierResponse = Operations
|
||||
.apiGet("/v1/accounts/username_hash/" + Base64.getUrlEncoder().encodeToString(reservedHash))
|
||||
.executeExpectSuccess(AccountIdentifierResponse.class);
|
||||
assertEquals(user.aciUuid(), accountIdentifierResponse.uuid());
|
||||
assertEquals(new AciServiceIdentifier(user.aciUuid()), accountIdentifierResponse.uuid());
|
||||
// try authorized
|
||||
Operations
|
||||
.apiGet("/v1/accounts/username_hash/" + Base64.getUrlEncoder().encodeToString(reservedHash))
|
||||
|
||||
@@ -8,11 +8,9 @@ package org.signal.integration;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
|
||||
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
|
||||
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
|
||||
@@ -21,27 +19,17 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class MessagingTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
public void testSendMessageUnsealed(final boolean atomicAccountCreation) throws Exception {
|
||||
final TestUser userA;
|
||||
final TestUser userB;
|
||||
|
||||
if (atomicAccountCreation) {
|
||||
userA = Operations.newRegisteredUserAtomic("+19995550102");
|
||||
userB = Operations.newRegisteredUserAtomic("+19995550103");
|
||||
} else {
|
||||
userA = Operations.newRegisteredUser("+19995550104");
|
||||
userB = Operations.newRegisteredUser("+19995550105");
|
||||
}
|
||||
@Test
|
||||
public void testSendMessageUnsealed() {
|
||||
final TestUser userA = Operations.newRegisteredUser("+19995550102");
|
||||
final TestUser userB = Operations.newRegisteredUser("+19995550103");
|
||||
|
||||
try {
|
||||
final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
|
||||
final String contentBase64 = Base64.getEncoder().encodeToString(expectedContent);
|
||||
final IncomingMessage message = new IncomingMessage(1, Device.MASTER_ID, userB.registrationId(), contentBase64);
|
||||
final IncomingMessage message = new IncomingMessage(1, Device.PRIMARY_ID, userB.registrationId(), expectedContent);
|
||||
final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());
|
||||
|
||||
final Pair<Integer, SendMessageResponse> sendMessage = Operations
|
||||
Operations
|
||||
.apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
|
||||
.authorized(userA)
|
||||
.execute(SendMessageResponse.class);
|
||||
@@ -50,7 +38,7 @@ public class MessagingTest {
|
||||
.authorized(userB)
|
||||
.execute(OutgoingMessageEntityList.class);
|
||||
|
||||
final byte[] actualContent = receiveMessages.getRight().messages().get(0).content();
|
||||
final byte[] actualContent = receiveMessages.getRight().messages().getFirst().content();
|
||||
assertArrayEquals(expectedContent, actualContent);
|
||||
} finally {
|
||||
Operations.deleteUser(userA);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import io.micrometer.common.util.StringUtils;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
|
||||
@@ -19,13 +20,18 @@ public class RegistrationTest {
|
||||
public void testRegistration() throws Exception {
|
||||
final UpdateVerificationSessionRequest originalRequest = new UpdateVerificationSessionRequest(
|
||||
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, null, null, null, null);
|
||||
final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest("+19995550102", originalRequest);
|
||||
|
||||
final Operations.PrescribedVerificationNumber params = Operations.prescribedVerificationNumber();
|
||||
final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest(params.number(),
|
||||
originalRequest);
|
||||
|
||||
final VerificationSessionResponse verificationSessionResponse = Operations
|
||||
.apiPost("/v1/verification/session", input)
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
|
||||
final String sessionId = verificationSessionResponse.id();
|
||||
Assertions.assertTrue(StringUtils.isNotBlank(sessionId));
|
||||
|
||||
final String pushChallenge = Operations.peekVerificationSessionPushChallenge(sessionId);
|
||||
|
||||
// supply push challenge
|
||||
@@ -46,7 +52,8 @@ public class RegistrationTest {
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
|
||||
// verify code
|
||||
final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest("265402");
|
||||
final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest(
|
||||
params.verificationCode());
|
||||
final VerificationSessionResponse codeVerified = Operations
|
||||
.apiPut("/v1/verification/session/%s/code".formatted(sessionId), submitVerificationCodeRequest)
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
|
||||
218
mvnw
vendored
218
mvnw
vendored
@@ -19,7 +19,7 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Maven Start Up Batch script
|
||||
# Apache Maven Wrapper startup batch script, version 3.2.0
|
||||
#
|
||||
# Required ENV vars:
|
||||
# ------------------
|
||||
@@ -27,7 +27,6 @@
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# M2_HOME - location of maven2's installed home dir
|
||||
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
|
||||
# e.g. to debug Maven itself, use
|
||||
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
|
||||
@@ -54,7 +53,7 @@ fi
|
||||
cygwin=false;
|
||||
darwin=false;
|
||||
mingw=false
|
||||
case "`uname`" in
|
||||
case "$(uname)" in
|
||||
CYGWIN*) cygwin=true ;;
|
||||
MINGW*) mingw=true;;
|
||||
Darwin*) darwin=true
|
||||
@@ -62,9 +61,9 @@ case "`uname`" in
|
||||
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
if [ -x "/usr/libexec/java_home" ]; then
|
||||
export JAVA_HOME="`/usr/libexec/java_home`"
|
||||
JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME
|
||||
else
|
||||
export JAVA_HOME="/Library/Java/Home"
|
||||
JAVA_HOME="/Library/Java/Home"; export JAVA_HOME
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
@@ -72,68 +71,38 @@ esac
|
||||
|
||||
if [ -z "$JAVA_HOME" ] ; then
|
||||
if [ -r /etc/gentoo-release ] ; then
|
||||
JAVA_HOME=`java-config --jre-home`
|
||||
JAVA_HOME=$(java-config --jre-home)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$M2_HOME" ] ; then
|
||||
## resolve links - $0 may be a link to maven's home
|
||||
PRG="$0"
|
||||
|
||||
# need this for relative symlinks
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG="`dirname "$PRG"`/$link"
|
||||
fi
|
||||
done
|
||||
|
||||
saveddir=`pwd`
|
||||
|
||||
M2_HOME=`dirname "$PRG"`/..
|
||||
|
||||
# make it fully qualified
|
||||
M2_HOME=`cd "$M2_HOME" && pwd`
|
||||
|
||||
cd "$saveddir"
|
||||
# echo Using m2 at $M2_HOME
|
||||
fi
|
||||
|
||||
# For Cygwin, ensure paths are in UNIX format before anything is touched
|
||||
if $cygwin ; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME=`cygpath --unix "$M2_HOME"`
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
|
||||
JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
|
||||
[ -n "$CLASSPATH" ] &&
|
||||
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
|
||||
CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
|
||||
fi
|
||||
|
||||
# For Mingw, ensure paths are in UNIX format before anything is touched
|
||||
if $mingw ; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME="`(cd "$M2_HOME"; pwd)`"
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
|
||||
[ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] &&
|
||||
JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)"
|
||||
fi
|
||||
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
javaExecutable="`which javac`"
|
||||
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
|
||||
javaExecutable="$(which javac)"
|
||||
if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then
|
||||
# readlink(1) is not available as standard on Solaris 10.
|
||||
readLink=`which readlink`
|
||||
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
|
||||
readLink=$(which readlink)
|
||||
if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
|
||||
if $darwin ; then
|
||||
javaHome="`dirname \"$javaExecutable\"`"
|
||||
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
|
||||
javaHome="$(dirname "\"$javaExecutable\"")"
|
||||
javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac"
|
||||
else
|
||||
javaExecutable="`readlink -f \"$javaExecutable\"`"
|
||||
javaExecutable="$(readlink -f "\"$javaExecutable\"")"
|
||||
fi
|
||||
javaHome="`dirname \"$javaExecutable\"`"
|
||||
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
|
||||
javaHome="$(dirname "\"$javaExecutable\"")"
|
||||
javaHome=$(expr "$javaHome" : '\(.*\)/bin')
|
||||
JAVA_HOME="$javaHome"
|
||||
export JAVA_HOME
|
||||
fi
|
||||
@@ -149,7 +118,7 @@ if [ -z "$JAVACMD" ] ; then
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
else
|
||||
JAVACMD="`\\unset -f command; \\command -v java`"
|
||||
JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -163,12 +132,9 @@ if [ -z "$JAVA_HOME" ] ; then
|
||||
echo "Warning: JAVA_HOME environment variable is not set."
|
||||
fi
|
||||
|
||||
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
|
||||
|
||||
# traverses directory structure from process work directory to filesystem root
|
||||
# first directory with .mvn subdirectory is considered project base directory
|
||||
find_maven_basedir() {
|
||||
|
||||
if [ -z "$1" ]
|
||||
then
|
||||
echo "Path not specified to find_maven_basedir"
|
||||
@@ -184,96 +150,99 @@ find_maven_basedir() {
|
||||
fi
|
||||
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
|
||||
if [ -d "${wdir}" ]; then
|
||||
wdir=`cd "$wdir/.."; pwd`
|
||||
wdir=$(cd "$wdir/.." || exit 1; pwd)
|
||||
fi
|
||||
# end of workaround
|
||||
done
|
||||
echo "${basedir}"
|
||||
printf '%s' "$(cd "$basedir" || exit 1; pwd)"
|
||||
}
|
||||
|
||||
# concatenates all lines of a file
|
||||
concat_lines() {
|
||||
if [ -f "$1" ]; then
|
||||
echo "$(tr -s '\n' ' ' < "$1")"
|
||||
# Remove \r in case we run on Windows within Git Bash
|
||||
# and check out the repository with auto CRLF management
|
||||
# enabled. Otherwise, we may read lines that are delimited with
|
||||
# \r\n and produce $'-Xarg\r' rather than -Xarg due to word
|
||||
# splitting rules.
|
||||
tr -s '\r\n' ' ' < "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
BASE_DIR=`find_maven_basedir "$(pwd)"`
|
||||
log() {
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
printf '%s\n' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
|
||||
if [ -z "$BASE_DIR" ]; then
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR
|
||||
log "$MAVEN_PROJECTBASEDIR"
|
||||
|
||||
##########################################################################################
|
||||
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
|
||||
# This allows using the maven wrapper in projects that prohibit checking in binary data.
|
||||
##########################################################################################
|
||||
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found .mvn/wrapper/maven-wrapper.jar"
|
||||
fi
|
||||
wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
|
||||
if [ -r "$wrapperJarPath" ]; then
|
||||
log "Found $wrapperJarPath"
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
|
||||
fi
|
||||
log "Couldn't find $wrapperJarPath, downloading it ..."
|
||||
|
||||
if [ -n "$MVNW_REPOURL" ]; then
|
||||
jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
|
||||
else
|
||||
jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
|
||||
fi
|
||||
while IFS="=" read key value; do
|
||||
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
|
||||
while IFS="=" read -r key value; do
|
||||
# Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
|
||||
safeValue=$(echo "$value" | tr -d '\r')
|
||||
case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;;
|
||||
esac
|
||||
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Downloading from: $jarUrl"
|
||||
fi
|
||||
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
|
||||
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
|
||||
log "Downloading from: $wrapperUrl"
|
||||
|
||||
if $cygwin; then
|
||||
wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
|
||||
wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
|
||||
fi
|
||||
|
||||
if command -v wget > /dev/null; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found wget ... using wget"
|
||||
fi
|
||||
log "Found wget ... using wget"
|
||||
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
|
||||
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
|
||||
wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
|
||||
wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
|
||||
else
|
||||
wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
|
||||
wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
|
||||
fi
|
||||
elif command -v curl > /dev/null; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found curl ... using curl"
|
||||
fi
|
||||
log "Found curl ... using curl"
|
||||
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
|
||||
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
|
||||
curl -o "$wrapperJarPath" "$jarUrl" -f
|
||||
curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
|
||||
else
|
||||
curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
|
||||
curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
|
||||
fi
|
||||
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Falling back to using Java to download"
|
||||
fi
|
||||
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
|
||||
log "Falling back to using Java to download"
|
||||
javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
|
||||
javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
|
||||
# For Cygwin, switch paths to Windows format before running javac
|
||||
if $cygwin; then
|
||||
javaClass=`cygpath --path --windows "$javaClass"`
|
||||
javaSource=$(cygpath --path --windows "$javaSource")
|
||||
javaClass=$(cygpath --path --windows "$javaClass")
|
||||
fi
|
||||
if [ -e "$javaClass" ]; then
|
||||
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Compiling MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
# Compiling the Java class
|
||||
("$JAVA_HOME/bin/javac" "$javaClass")
|
||||
if [ -e "$javaSource" ]; then
|
||||
if [ ! -e "$javaClass" ]; then
|
||||
log " - Compiling MavenWrapperDownloader.java ..."
|
||||
("$JAVA_HOME/bin/javac" "$javaSource")
|
||||
fi
|
||||
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
|
||||
# Running the downloader
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Running MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
|
||||
if [ -e "$javaClass" ]; then
|
||||
log " - Running MavenWrapperDownloader.java ..."
|
||||
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -282,35 +251,58 @@ fi
|
||||
# End of extension
|
||||
##########################################################################################
|
||||
|
||||
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo $MAVEN_PROJECTBASEDIR
|
||||
# If specified, validate the SHA-256 sum of the Maven wrapper jar file
|
||||
wrapperSha256Sum=""
|
||||
while IFS="=" read -r key value; do
|
||||
case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;;
|
||||
esac
|
||||
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
|
||||
if [ -n "$wrapperSha256Sum" ]; then
|
||||
wrapperSha256Result=false
|
||||
if command -v sha256sum > /dev/null; then
|
||||
if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c - > /dev/null 2>&1; then
|
||||
wrapperSha256Result=true
|
||||
fi
|
||||
elif command -v shasum > /dev/null; then
|
||||
if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then
|
||||
wrapperSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available."
|
||||
echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties."
|
||||
exit 1
|
||||
fi
|
||||
if [ $wrapperSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
|
||||
echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME=`cygpath --path --windows "$M2_HOME"`
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
|
||||
JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
|
||||
[ -n "$CLASSPATH" ] &&
|
||||
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
|
||||
CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
|
||||
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
|
||||
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
|
||||
MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
|
||||
fi
|
||||
|
||||
# Provide a "standardized" way to retrieve the CLI args that will
|
||||
# work with both Windows and non-Windows executions.
|
||||
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
|
||||
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
|
||||
export MAVEN_CMD_LINE_ARGS
|
||||
|
||||
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
|
||||
|
||||
# shellcheck disable=SC2086 # safe args
|
||||
exec "$JAVACMD" \
|
||||
$MAVEN_OPTS \
|
||||
$MAVEN_DEBUG_OPTS \
|
||||
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
|
||||
"-Dmaven.home=${M2_HOME}" \
|
||||
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
|
||||
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
|
||||
|
||||
31
mvnw.cmd
vendored
31
mvnw.cmd
vendored
@@ -18,13 +18,12 @@
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Maven Start Up Batch script
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.2.0
|
||||
@REM
|
||||
@REM Required ENV vars:
|
||||
@REM JAVA_HOME - location of a JDK home dir
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM M2_HOME - location of maven2's installed home dir
|
||||
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
|
||||
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
|
||||
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
|
||||
@@ -120,10 +119,10 @@ SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
|
||||
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
|
||||
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
|
||||
|
||||
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
|
||||
|
||||
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
|
||||
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
|
||||
IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
|
||||
)
|
||||
|
||||
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
|
||||
@@ -134,11 +133,11 @@ if exist %WRAPPER_JAR% (
|
||||
)
|
||||
) else (
|
||||
if not "%MVNW_REPOURL%" == "" (
|
||||
SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
|
||||
)
|
||||
if "%MVNW_VERBOSE%" == "true" (
|
||||
echo Couldn't find %WRAPPER_JAR%, downloading it ...
|
||||
echo Downloading from: %DOWNLOAD_URL%
|
||||
echo Downloading from: %WRAPPER_URL%
|
||||
)
|
||||
|
||||
powershell -Command "&{"^
|
||||
@@ -146,7 +145,7 @@ if exist %WRAPPER_JAR% (
|
||||
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
|
||||
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
|
||||
"}"^
|
||||
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
|
||||
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
|
||||
"}"
|
||||
if "%MVNW_VERBOSE%" == "true" (
|
||||
echo Finished downloading %WRAPPER_JAR%
|
||||
@@ -154,6 +153,24 @@ if exist %WRAPPER_JAR% (
|
||||
)
|
||||
@REM End of extension
|
||||
|
||||
@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
|
||||
SET WRAPPER_SHA_256_SUM=""
|
||||
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
|
||||
IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
|
||||
)
|
||||
IF NOT %WRAPPER_SHA_256_SUM%=="" (
|
||||
powershell -Command "&{"^
|
||||
"$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
|
||||
"If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
|
||||
" Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
|
||||
" Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
|
||||
" Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
|
||||
" exit 1;"^
|
||||
"}"^
|
||||
"}"
|
||||
if ERRORLEVEL 1 goto error
|
||||
)
|
||||
|
||||
@REM Provide a "standardized" way to retrieve the CLI args that will
|
||||
@REM work with both Windows and non-Windows executions.
|
||||
set MAVEN_CMD_LINE_ARGS=%*
|
||||
|
||||
318
pom.xml
318
pom.xml
@@ -31,45 +31,66 @@
|
||||
|
||||
<modules>
|
||||
<module>api-doc</module>
|
||||
<module>event-logger</module>
|
||||
<module>integration-tests</module>
|
||||
<module>service</module>
|
||||
<module>websocket-resources</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<aws.sdk2.version>2.20.118</aws.sdk2.version>
|
||||
<braintree.version>3.19.0</braintree.version>
|
||||
<commons-csv.version>1.9.0</commons-csv.version>
|
||||
<commons-io.version>2.9.0</commons-io.version>
|
||||
<dropwizard.version>2.1.7</dropwizard.version>
|
||||
<dropwizard-metrics-datadog.version>1.1.13</dropwizard-metrics-datadog.version>
|
||||
<google-cloud-libraries.version>26.21.0</google-cloud-libraries.version>
|
||||
<grpc.version>1.56.1</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
|
||||
<gson.version>2.9.0</gson.version>
|
||||
<jackson.version>2.13.5</jackson.version>
|
||||
<jaxb.version>2.3.1</jaxb.version>
|
||||
<kotlin.version>1.8.0</kotlin.version>
|
||||
<kotlinx-serialization.version>1.4.1</kotlinx-serialization.version>
|
||||
<lettuce.version>6.2.4.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||
<logstash.logback.version>7.2</logstash.logback.version>
|
||||
<luajava.version>3.4.0</luajava.version>
|
||||
<micrometer.version>1.10.3</micrometer.version>
|
||||
<mockito.version>4.11.0</mockito.version>
|
||||
<netty.version>4.1.96.Final</netty.version>
|
||||
<opentest4j.version>1.2.0</opentest4j.version>
|
||||
<protobuf.version>3.23.2</protobuf.version> <!-- should be kept in sync with the value from Google libraries-bom -->
|
||||
<pushy.version>0.15.2</pushy.version>
|
||||
<aws.sdk2.version>2.33.8</aws.sdk2.version>
|
||||
<braintree.version>3.44.0</braintree.version>
|
||||
<commons-csv.version>1.14.1</commons-csv.version>
|
||||
<commons-io.version>2.20.0</commons-io.version>
|
||||
<dropwizard.version>4.0.16</dropwizard.version>
|
||||
<!-- Note: when updating FoundationDB, also include a copy of `libfdb_c.so` from the FoundationDB release at
|
||||
src/main/jib/usr/lib/libfdb_c.so. We use x86_64 builds without AVX instructions enabled (i.e. FoundationDB versions
|
||||
with even-numbered patch versions). Also when updating FoundationDB, make sure to update the version of FoundationDB
|
||||
used by GitHub Actions. -->
|
||||
<foundationdb.version>7.3.62</foundationdb.version>
|
||||
<foundationdb.api-version>730</foundationdb.api-version>
|
||||
<foundationdb.client-library-sha256>bfed237b787fae3cde1222676e6bfbb0d218fc27bf9e903397a7a7aa96fb2d33</foundationdb.client-library-sha256>
|
||||
<google-cloud-libraries.version>26.67.0</google-cloud-libraries.version>
|
||||
<grpc.version>1.73.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
|
||||
<gson.version>2.13.2</gson.version>
|
||||
<!-- several libraries (AWS, Google Cloud) use Apache http components transitively, and we need to align them -->
|
||||
<httpcore.version>4.4.16</httpcore.version>
|
||||
<httpclient.version>4.5.14</httpclient.version>
|
||||
<jackson.version>2.20.0</jackson.version>
|
||||
<junit-pioneer.version>2.3.0</junit-pioneer.version>
|
||||
<jsr305.version>3.0.2</jsr305.version>
|
||||
<kotlin.version>2.2.20</kotlin.version>
|
||||
<!-- Logback 1.5.14+ has a null pointer bug: https://github.com/qos-ch/logback/issues/929. -->
|
||||
<logback.version>1.5.13</logback.version>
|
||||
<logback-access-common.version>2.0.6</logback-access-common.version>
|
||||
<lettuce.version>6.8.1.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>9.0.13</libphonenumber.version>
|
||||
<logstash.logback.version>8.1</logstash.logback.version>
|
||||
<log4j-bom.version>2.25.1</log4j-bom.version>
|
||||
<luajava.version>3.5.0</luajava.version>
|
||||
<micrometer.version>1.15.4</micrometer.version>
|
||||
<netty.version>4.1.127.Final</netty.version>
|
||||
<!-- Must be less than or equal to the value from Google libraries-bom which controls the protobuf runtime version.
|
||||
See https://protobuf.dev/support/cross-version-runtime-guarantee/. -->
|
||||
<protoc.version>4.29.4</protoc.version>
|
||||
<pushy.version>0.15.4</pushy.version>
|
||||
<reactive.grpc.version>1.2.4</reactive.grpc.version>
|
||||
<resilience4j.version>1.7.0</resilience4j.version>
|
||||
<reactor-bom.version>2024.0.10</reactor-bom.version> <!-- 3.7.11, see https://github.com/reactor/reactor#bom-versioning-scheme -->
|
||||
<resilience4j.version>2.3.0</resilience4j.version>
|
||||
<semver4j.version>3.1.0</semver4j.version>
|
||||
<slf4j.version>1.7.30</slf4j.version>
|
||||
<stripe.version>21.2.0</stripe.version>
|
||||
<vavr.version>0.10.4</vavr.version>
|
||||
<simple-grpc.version>0.1.0</simple-grpc.version>
|
||||
<slf4j.version>2.0.17</slf4j.version>
|
||||
<stripe.version>30.2.0</stripe.version>
|
||||
<swagger.version>2.2.36</swagger.version>
|
||||
<testcontainers.version>1.21.3</testcontainers.version>
|
||||
|
||||
<!-- 17.0.7_7-jre-jammy -->
|
||||
<docker.image.sha256>ddf36656dc8920621fddf4928bdcb4b98c0d0e7bc9672f0cea8115c10ad5cbc6</docker.image.sha256>
|
||||
<!-- images to use in tests via testcontainers -->
|
||||
<dynamodb.image>amazon/dynamodb-local:3.0.0@sha256:2fed5e3a965a4ba5aa6ac82baec57058b5a3848e959d705518f3fd579a77e76b</dynamodb.image>
|
||||
<localstack.image>localstack/localstack:4@sha256:5a97e0f9917a3f0d9630bb13b9d8ccf10cbe52f33252807d3b4e21418cc21348</localstack.image>
|
||||
<redis.image>redis:7.4-alpine@sha256:af1d0fc3f63b02b13ff7906c9baf7c5b390b8881ca08119cd570677fe2f60b55</redis.image>
|
||||
<redis-cluster.image>docker.io/bitnamilegacy/redis-cluster:7.4.3@sha256:a53d023fdfaf8a8d7ddc58da040d3494e4cb45772644618ffa44c42dcd32b9af</redis-cluster.image>
|
||||
|
||||
<!-- eclipse-temurin:24.0.2_12-jre-noble (note: always use the multi-arch manifest *LIST* here) -->
|
||||
<docker.image.sha256>85ecfc9bbb42af046d2bacbf1219d2005be4840cbfa16c2e6fd910d9ccfec95b</docker.image.sha256>
|
||||
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
@@ -141,10 +162,24 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-bom</artifactId>
|
||||
<version>1.54.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry.instrumentation</groupId>
|
||||
<artifactId>opentelemetry-instrumentation-bom</artifactId>
|
||||
<version>2.20.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-bom</artifactId>
|
||||
<version>2022.0.3</version> <!-- 3.5.x, see https://github.com/reactor/reactor#bom-versioning-scheme -->
|
||||
<version>${reactor-bom.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -165,11 +200,6 @@
|
||||
<artifactId>pushy-dropwizard-metrics-listener</artifactId>
|
||||
<version>${pushy.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
<version>${protobuf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>libphonenumber</artifactId>
|
||||
@@ -190,16 +220,6 @@
|
||||
<artifactId>lettuce-core</artifactId>
|
||||
<version>${lettuce.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vavr</groupId>
|
||||
<artifactId>vavr</artifactId>
|
||||
<version>${vavr.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>javax.xml.bind</groupId>
|
||||
<artifactId>jaxb-api</artifactId>
|
||||
<version>${jaxb.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.logstash.logback</groupId>
|
||||
<artifactId>logstash-logback-encoder</artifactId>
|
||||
@@ -211,33 +231,9 @@
|
||||
<version>${commons-csv.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.coursera</groupId>
|
||||
<artifactId>dropwizard-metrics-datadog</artifactId>
|
||||
<version>${dropwizard-metrics-datadog.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jaxb</groupId>
|
||||
<artifactId>jaxb-runtime</artifactId>
|
||||
<version>${jaxb.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-inline</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.opentest4j</groupId>
|
||||
<artifactId>opentest4j</artifactId>
|
||||
<version>${opentest4j.version}</version>
|
||||
<scope>test</scope>
|
||||
<groupId>org.foundationdb</groupId>
|
||||
<artifactId>fdb-java</artifactId>
|
||||
<version>${foundationdb.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
@@ -253,12 +249,12 @@
|
||||
<dependency>
|
||||
<groupId>commons-logging</groupId>
|
||||
<artifactId>commons-logging</artifactId>
|
||||
<version>1.2</version>
|
||||
<version>1.3.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.ow2.asm</groupId>
|
||||
<artifactId>asm</artifactId>
|
||||
<version>9.2</version>
|
||||
<version>9.8</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -271,29 +267,77 @@
|
||||
<artifactId>braintree-java</artifactId>
|
||||
<version>${braintree.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.findbugs</groupId>
|
||||
<artifactId>jsr305</artifactId>
|
||||
<version>${jsr305.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>${gson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>embedded-redis</artifactId>
|
||||
<version>0.8.3</version>
|
||||
<groupId>com.redis</groupId>
|
||||
<artifactId>testcontainers-redis</artifactId>
|
||||
<version>2.2.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>libsignal-server</artifactId>
|
||||
<version>0.30.0</version>
|
||||
<version>0.80.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>simple-grpc-runtime</artifactId>
|
||||
<version>${simple-grpc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-bom</artifactId>
|
||||
<version>2.17.1</version>
|
||||
<version>${log4j-bom.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpcore</artifactId>
|
||||
<version>${httpcore.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>${httpclient.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-core</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback.access</groupId>
|
||||
<artifactId>logback-access-common</artifactId>
|
||||
<version>${logback-access-common.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-bom</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>earth.adi</groupId>
|
||||
<artifactId>testcontainers-foundationdb</artifactId>
|
||||
<version>1.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
@@ -305,25 +349,18 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock-jre8</artifactId>
|
||||
<version>2.35.0</version>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>aws-crt-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock</artifactId>
|
||||
<version>3.13.1</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.hamcrest</groupId>
|
||||
<artifactId>hamcrest-core</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>javax.xml.bind</groupId>
|
||||
<artifactId>jaxb-api</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -339,7 +376,7 @@
|
||||
<dependency>
|
||||
<groupId>org.junit-pioneer</groupId>
|
||||
<artifactId>junit-pioneer</artifactId>
|
||||
<version>1.9.1</version>
|
||||
<version>${junit-pioneer.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -381,7 +418,42 @@
|
||||
<plugin>
|
||||
<groupId>com.google.cloud.tools</groupId>
|
||||
<artifactId>jib-maven-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<version>3.4.4</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.7.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>properties-maven-plugin</artifactId>
|
||||
<version>1.2.1</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
@@ -393,7 +465,7 @@
|
||||
<version>0.6.1</version>
|
||||
<configuration>
|
||||
<checkStaleness>false</checkStaleness>
|
||||
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
|
||||
<protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}</protocArtifact>
|
||||
<pluginId>grpc-java</pluginId>
|
||||
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
|
||||
|
||||
@@ -405,6 +477,14 @@
|
||||
<version>${reactive.grpc.version}</version>
|
||||
<mainClass>com.salesforce.reactorgrpc.ReactorGrpcGenerator</mainClass>
|
||||
</protocPlugin>
|
||||
|
||||
<protocPlugin>
|
||||
<id>simple</id>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>simple-grpc-generator</artifactId>
|
||||
<version>${simple-grpc.version}</version>
|
||||
<mainClass>org.signal.grpc.simple.SimpleGrpcGenerator</mainClass>
|
||||
</protocPlugin>
|
||||
</protocPlugins>
|
||||
</configuration>
|
||||
<executions>
|
||||
@@ -422,16 +502,16 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>17</release>
|
||||
<release>24</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<version>3.4.2</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
@@ -444,41 +524,27 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<version>3.8.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy</id>
|
||||
<phase>test-compile</phase>
|
||||
<!--
|
||||
Set dependencies as properties for use in argLine property for mockito jar.
|
||||
The property isn't needed until the test phase, and deferring it from the default
|
||||
`initialize` addresses issues running lifecycle phases that precede `test` in isolation.
|
||||
-->
|
||||
<phase>process-test-classes</phase>
|
||||
<goals>
|
||||
<goal>copy-dependencies</goal>
|
||||
<goal>properties</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<includeScope>test</includeScope>
|
||||
<includeTypes>so,dll,dylib</includeTypes>
|
||||
<outputDirectory>${project.build.directory}/lib</outputDirectory>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M5</version>
|
||||
<configuration>
|
||||
<systemProperties>
|
||||
<property>
|
||||
<name>sqlite4java.library.path</name>
|
||||
<value>${project.build.directory}/lib</value>
|
||||
</property>
|
||||
</systemProperties>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.0.0-M3</version>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -488,7 +554,7 @@
|
||||
<rules>
|
||||
<dependencyConvergence/>
|
||||
<requireMavenVersion>
|
||||
<version>3.8.6</version>
|
||||
<version>3.9.11</version>
|
||||
</requireMavenVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
@@ -499,7 +565,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-install-plugin</artifactId>
|
||||
<version>3.0.0-M1</version>
|
||||
<version>3.1.3</version>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
@@ -508,7 +574,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>3.0.0-M1</version>
|
||||
<version>3.1.3</version>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
datadog.apiKey: unset
|
||||
|
||||
stripe.apiKey: unset
|
||||
stripe.idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
||||
|
||||
braintree.publicKey: unset
|
||||
braintree.privateKey: unset
|
||||
|
||||
googlePlayBilling.credentialsJson: |
|
||||
{ "json": true }
|
||||
|
||||
appleAppStore.encodedKey: unset
|
||||
|
||||
directoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users
|
||||
directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users
|
||||
|
||||
svr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users
|
||||
svr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users
|
||||
|
||||
tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=
|
||||
svrb.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth tokens for Signal users
|
||||
svrb.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth identity tokens for Signal users
|
||||
|
||||
awsAttachments.accessKey: test
|
||||
awsAttachments.accessSecret: test
|
||||
tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=
|
||||
|
||||
gcpAttachments.rsaSigningKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
@@ -46,6 +50,8 @@ gcpAttachments.rsaSigningKey: |
|
||||
AAAAAAAA
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
apn.teamId: team-id
|
||||
apn.keyId: key-id
|
||||
apn.signingKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
@@ -60,29 +66,37 @@ fcm.credentials: |
|
||||
cdn.accessKey: test # AWS Access Key ID
|
||||
cdn.accessSecret: test # AWS Access Secret
|
||||
|
||||
unidentifiedDelivery.certificate: ABCD1234
|
||||
cdn3StorageManager.clientSecret: test
|
||||
|
||||
unidentifiedDelivery.privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA
|
||||
|
||||
hCaptcha.apiKey: unset
|
||||
keyTransparencyService.clientPrivateKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAA
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
storageService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
|
||||
backupService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
|
||||
zkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
|
||||
zkConfig-libsignal-0.42.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdef
|
||||
|
||||
genericZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
|
||||
callingZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
|
||||
backupsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==
|
||||
|
||||
paymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
|
||||
paymentsService.fixerApiKey: unset
|
||||
paymentsService.coinMarketCapApiKey: unset
|
||||
|
||||
artService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret not shared with any external service, but used in ArtController
|
||||
artService.userAuthenticationTokenUserIdSecret: AAAAAAAAAAA= # base64-encoded secret to obscure user phone numbers from Sticker Creator
|
||||
paymentsService.coinGeckoApiKey: unset
|
||||
|
||||
currentReportingKey.secret: AAAAAAAAAAA=
|
||||
currentReportingKey.salt: AAAAAAAAAAA=
|
||||
|
||||
turn.secret: AAAAAAAAAAA=
|
||||
registrationService.collationKeySalt: AAAAAAAAAAA=
|
||||
|
||||
turn.cloudflare.apiToken: ABCDEFGHIJKLM
|
||||
|
||||
linkDevice.secret: AAAAAAAAAAA=
|
||||
|
||||
tlsKeyStore.password: unset
|
||||
|
||||
@@ -10,72 +10,70 @@ logging:
|
||||
threshold: ALL
|
||||
timeZone: UTC
|
||||
target: stdout
|
||||
- type: logstashtcpsocket
|
||||
destination: example.com:10516
|
||||
apiKey: secret://datadog.apiKey
|
||||
environment: staging
|
||||
- type: otlp
|
||||
|
||||
metrics:
|
||||
reporters:
|
||||
- type: signal-datadog
|
||||
frequency: 10 seconds
|
||||
tags:
|
||||
- "env:staging"
|
||||
- "service:chat"
|
||||
udpTransport:
|
||||
statsdHost: localhost
|
||||
port: 8125
|
||||
excludesAttributes:
|
||||
- m1_rate
|
||||
- m5_rate
|
||||
- m15_rate
|
||||
- mean_rate
|
||||
- stddev
|
||||
useRegexFilters: true
|
||||
excludes:
|
||||
- ^.+\.total$
|
||||
- ^.+\.request\.filtering$
|
||||
- ^.+\.response\.filtering$
|
||||
- ^executor\..+$
|
||||
- ^lettuce\..+$
|
||||
reportOnStop: true
|
||||
|
||||
adminEventLoggingConfiguration:
|
||||
credentials: |
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
projectId: some-project-id
|
||||
logName: some-log-name
|
||||
|
||||
grpcPort: 8080
|
||||
tlsKeyStore:
|
||||
password: secret://tlsKeyStore.password
|
||||
|
||||
stripe:
|
||||
apiKey: secret://stripe.apiKey
|
||||
idempotencyKeyGenerator: secret://stripe.idempotencyKeyGenerator
|
||||
boostDescription: >
|
||||
Example
|
||||
supportedCurrencies:
|
||||
- xts
|
||||
# - ...
|
||||
# - Nth supported currency
|
||||
|
||||
supportedCurrenciesByPaymentMethod:
|
||||
CARD:
|
||||
- usd
|
||||
- eur
|
||||
SEPA_DEBIT:
|
||||
- eur
|
||||
|
||||
braintree:
|
||||
merchantId: unset
|
||||
publicKey: unset
|
||||
publicKey: secret://braintree.publicKey
|
||||
privateKey: secret://braintree.privateKey
|
||||
environment: unset
|
||||
graphqlUrl: unset
|
||||
merchantAccounts:
|
||||
# ISO 4217 currency code and its corresponding sub-merchant account
|
||||
'xts': unset
|
||||
supportedCurrencies:
|
||||
- xts
|
||||
# - ...
|
||||
# - Nth supported currency
|
||||
supportedCurrenciesByPaymentMethod:
|
||||
PAYPAL:
|
||||
- usd
|
||||
pubSubPublisher:
|
||||
project: example-project
|
||||
topic: example-topic
|
||||
credentialConfiguration: |
|
||||
{
|
||||
"credential": "configuration"
|
||||
}
|
||||
|
||||
dynamoDbClientConfiguration:
|
||||
googlePlayBilling:
|
||||
credentialsJson: secret://googlePlayBilling.credentialsJson
|
||||
packageName: package.name
|
||||
applicationName: test
|
||||
productIdToLevel: {}
|
||||
|
||||
appleAppStore:
|
||||
env: SANDBOX
|
||||
bundleId: bundle.name
|
||||
appAppleId: 12345
|
||||
issuerId: abcdefg
|
||||
keyId: abcdefg
|
||||
encodedKey: secret://appleAppStore.encodedKey
|
||||
subscriptionGroupId: example_subscriptionGroupId
|
||||
productIdToLevel: {}
|
||||
appleRootCerts: []
|
||||
|
||||
appleDeviceCheck:
|
||||
production: false
|
||||
teamId: 0123456789
|
||||
bundleId: bundle.name
|
||||
|
||||
deviceCheck:
|
||||
backupRedemptionDuration: P30D
|
||||
backupRedemptionLevel: 201
|
||||
|
||||
dynamoDbClient:
|
||||
region: us-west-2 # AWS Region
|
||||
|
||||
dynamoDbTables:
|
||||
@@ -84,7 +82,13 @@ dynamoDbTables:
|
||||
phoneNumberTableName: Example_Accounts_PhoneNumbers
|
||||
phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers
|
||||
usernamesTableName: Example_Accounts_Usernames
|
||||
scanPageSize: 100
|
||||
usedLinkDeviceTokensTableName: Example_Accounts_UsedLinkDeviceTokens
|
||||
appleDeviceChecks:
|
||||
tableName: Example_AppleDeviceChecks
|
||||
appleDeviceCheckPublicKeys:
|
||||
tableName: Example_AppleDeviceCheckPublicKeys
|
||||
backups:
|
||||
tableName: Example_Backups
|
||||
clientReleases:
|
||||
tableName: Example_ClientReleases
|
||||
deletedAccounts:
|
||||
@@ -95,23 +99,35 @@ dynamoDbTables:
|
||||
tableName: Example_IssuedReceipts
|
||||
expiration: P30D # Duration of time until rows expire
|
||||
generator: abcdefg12345678= # random base64-encoded binary sequence
|
||||
maxIssuedReceiptsPerPaymentId:
|
||||
STRIPE: 1
|
||||
BRAINTREE: 1
|
||||
GOOGLE_PLAY_BILLING: 1
|
||||
APPLE_APP_STORE: 1
|
||||
ecKeys:
|
||||
tableName: Example_Keys
|
||||
ecSignedPreKeys:
|
||||
tableName: Example_EC_Signed_Pre_Keys
|
||||
pqKeys:
|
||||
tableName: Example_PQ_Keys
|
||||
pagedPqKeys:
|
||||
tableName: Example_PQ_Paged_Keys
|
||||
pqLastResortKeys:
|
||||
tableName: Example_PQ_Last_Resort_Keys
|
||||
messages:
|
||||
tableName: Example_Messages
|
||||
expiration: P30D # Duration of time until rows expire
|
||||
onetimeDonations:
|
||||
tableName: Example_OnetimeDonations
|
||||
expiration: P90D
|
||||
phoneNumberIdentifiers:
|
||||
tableName: Example_PhoneNumberIdentifiers
|
||||
profiles:
|
||||
tableName: Example_Profiles
|
||||
pushChallenge:
|
||||
tableName: Example_PushChallenge
|
||||
pushNotificationExperimentSamples:
|
||||
tableName: Example_PushNotificationExperimentSamples
|
||||
redeemedReceipts:
|
||||
tableName: Example_RedeemedReceipts
|
||||
expiration: P30D # Duration of time until rows expire
|
||||
@@ -122,15 +138,21 @@ dynamoDbTables:
|
||||
tableName: Example_RemoteConfig
|
||||
reportMessage:
|
||||
tableName: Example_ReportMessage
|
||||
scheduledJobs:
|
||||
tableName: Example_ScheduledJobs
|
||||
expiration: P7D
|
||||
subscriptions:
|
||||
tableName: Example_Subscriptions
|
||||
clientPublicKeys:
|
||||
tableName: Example_ClientPublicKeys
|
||||
verificationSessions:
|
||||
tableName: Example_VerificationSessions
|
||||
|
||||
cacheCluster: # Redis server configuration for cache cluster
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
pagedSingleUseKEMPreKeyStore:
|
||||
bucket: preKeyBucket # S3 Bucket name
|
||||
region: us-west-2 # AWS region
|
||||
|
||||
clientPresenceCluster: # Redis server configuration for client presence cluster
|
||||
cacheCluster: # Redis server configuration for cache cluster
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
|
||||
pubsub: # Redis server configuration for pubsub cluster
|
||||
@@ -175,21 +197,39 @@ svr2:
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
svrb:
|
||||
uri: svrb.example.com
|
||||
userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret
|
||||
userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret
|
||||
svrCaCertificates:
|
||||
- |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
messageCache: # Redis server configuration for message store cache
|
||||
persistDelayMinutes: 1
|
||||
cluster:
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
|
||||
metricsCluster:
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
|
||||
awsAttachments: # AWS S3 configuration
|
||||
accessKey: secret://awsAttachments.accessKey
|
||||
accessSecret: secret://awsAttachments.accessSecret
|
||||
bucket: aws-attachments
|
||||
region: us-west-2
|
||||
|
||||
gcpAttachments: # GCP Storage configuration
|
||||
domain: example.com
|
||||
email: user@example.cocm
|
||||
@@ -201,40 +241,41 @@ tus:
|
||||
uploadUri: https://example.org/upload
|
||||
userAuthenticationTokenSharedSecret: secret://tus.userAuthenticationTokenSharedSecret
|
||||
|
||||
accountDatabaseCrawler:
|
||||
chunkSize: 10 # accounts per run
|
||||
|
||||
apn: # Apple Push Notifications configuration
|
||||
sandbox: true
|
||||
bundleId: com.example.textsecuregcm
|
||||
keyId: unset
|
||||
teamId: unset
|
||||
keyId: secret://apn.keyId
|
||||
teamId: secret://apn.teamId
|
||||
signingKey: secret://apn.signingKey
|
||||
|
||||
fcm: # FCM configuration
|
||||
credentials: secret://fcm.credentials
|
||||
|
||||
cdn:
|
||||
accessKey: secret://cdn.accessKey
|
||||
accessSecret: secret://cdn.accessSecret
|
||||
bucket: cdn # S3 Bucket name
|
||||
credentials:
|
||||
accessKeyId: secret://cdn.accessKey
|
||||
secretAccessKey: secret://cdn.accessSecret
|
||||
region: us-west-2 # AWS region
|
||||
|
||||
dogstatsd:
|
||||
cdn3StorageManager:
|
||||
baseUri: https://storage-manager.example.com
|
||||
clientId: example
|
||||
clientSecret: secret://cdn3StorageManager.clientSecret
|
||||
sourceSchemes:
|
||||
2: gcs
|
||||
3: r2
|
||||
|
||||
openTelemetry:
|
||||
enabled: true
|
||||
environment: dev
|
||||
url: http://127.0.0.1:4318/
|
||||
|
||||
unidentifiedDelivery:
|
||||
certificate: secret://unidentifiedDelivery.certificate
|
||||
certificate: CgIIAQ==
|
||||
privateKey: secret://unidentifiedDelivery.privateKey
|
||||
expiresDays: 7
|
||||
|
||||
recaptcha:
|
||||
projectPath: projects/example
|
||||
credentialConfigurationJson: "{ }" # service account configuration for backend authentication
|
||||
|
||||
hCaptcha:
|
||||
apiKey: secret://hCaptcha.apiKey
|
||||
|
||||
shortCode:
|
||||
baseUrl: https://example.com/shortcodes/
|
||||
|
||||
@@ -265,73 +306,37 @@ storageService:
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
backupService:
|
||||
uri: backup.example.com
|
||||
userAuthenticationTokenSharedSecret: secret://backupService.userAuthenticationTokenSharedSecret
|
||||
backupCaCertificates:
|
||||
- |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
zkConfig:
|
||||
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
serverSecret: secret://zkConfig.serverSecret
|
||||
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAB==
|
||||
serverSecret: secret://zkConfig-libsignal-0.42.serverSecret
|
||||
|
||||
genericZkConfig:
|
||||
serverSecret: secret://genericZkConfig.serverSecret
|
||||
callingZkConfig:
|
||||
serverSecret: secret://callingZkConfig.serverSecret
|
||||
|
||||
appConfig:
|
||||
application: example
|
||||
environment: example
|
||||
configuration: example
|
||||
backupsZkConfig:
|
||||
serverSecret: secret://backupsZkConfig.serverSecret
|
||||
|
||||
dynamicConfig:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: dynamic-config.yaml
|
||||
maxSize: 100000
|
||||
refreshInterval: PT10S
|
||||
|
||||
remoteConfig:
|
||||
authorizedUsers:
|
||||
- # 1st authorized user
|
||||
- # 2nd authorized user
|
||||
- # ...
|
||||
- # Nth authorized user
|
||||
requiredHostedDomain: example.com
|
||||
audiences:
|
||||
- # 1st audience
|
||||
- # 2nd audience
|
||||
- # ...
|
||||
- # Nth audience
|
||||
globalConfig: # keys and values that are given to clients on GET /v1/config
|
||||
EXAMPLE_KEY: VALUE
|
||||
|
||||
paymentsService:
|
||||
userAuthenticationTokenSharedSecret: secret://paymentsService.userAuthenticationTokenSharedSecret
|
||||
fixerApiKey: secret://paymentsService.fixerApiKey
|
||||
coinMarketCapApiKey: secret://paymentsService.coinMarketCapApiKey
|
||||
coinMarketCapCurrencyIds:
|
||||
MOB: 7878
|
||||
paymentCurrencies:
|
||||
# list of symbols for supported currencies
|
||||
- MOB
|
||||
|
||||
artService:
|
||||
userAuthenticationTokenSharedSecret: secret://artService.userAuthenticationTokenSharedSecret
|
||||
userAuthenticationTokenUserIdSecret: secret://artService.userAuthenticationTokenUserIdSecret
|
||||
externalClients:
|
||||
fixerApiKey: secret://paymentsService.fixerApiKey
|
||||
coinGeckoApiKey: secret://paymentsService.coinGeckoApiKey
|
||||
coinGeckoCurrencyIds:
|
||||
MOB: mobilecoin
|
||||
|
||||
badges:
|
||||
badges:
|
||||
@@ -354,7 +359,11 @@ badges:
|
||||
'1': TEST
|
||||
|
||||
subscription: # configuration for Stripe subscriptions
|
||||
badgeExpiration: P30D
|
||||
badgeGracePeriod: P15D
|
||||
backupExpiration: P30D
|
||||
backupGracePeriod: P15D
|
||||
backupFreeTierMediaDuration: P30D
|
||||
levels:
|
||||
500:
|
||||
badge: EXAMPLE
|
||||
@@ -367,6 +376,7 @@ subscription: # configuration for Stripe subscriptions
|
||||
BRAINTREE: plan_example # braintree Plan ID
|
||||
|
||||
oneTimeDonations:
|
||||
sepaMaximumEuros: '10000'
|
||||
boost:
|
||||
level: 1
|
||||
expiration: P90D
|
||||
@@ -396,6 +406,7 @@ registrationService:
|
||||
"example": "example"
|
||||
}
|
||||
identityTokenAudience: https://registration.example.com
|
||||
collationKeySalt: secret://registrationService.collationKeySalt
|
||||
registrationCaCertificate: | # Registration service TLS certificate trust root
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
@@ -419,11 +430,99 @@ registrationService:
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
turn:
|
||||
secret: secret://turn.secret
|
||||
keyTransparencyService:
|
||||
host: kt.example.com
|
||||
port: 443
|
||||
tlsCertificate: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
clientCertificate: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
clientPrivateKey: secret://keyTransparencyService.clientPrivateKey
|
||||
|
||||
commandStopListener:
|
||||
path: /example/path
|
||||
turn:
|
||||
cloudflare:
|
||||
apiToken: secret://turn.cloudflare.apiToken
|
||||
endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
|
||||
urls:
|
||||
- turn:turn.example.com:80
|
||||
urlsWithIps:
|
||||
- turn:%s
|
||||
- turn:%s:80?transport=tcp
|
||||
- turns:%s:443?transport=tcp
|
||||
requestedCredentialTtl: PT24H
|
||||
clientCredentialTtl: PT12H
|
||||
hostname: turn.cloudflare.example.com
|
||||
numHttpClients: 1
|
||||
|
||||
linkDevice:
|
||||
secret: secret://linkDevice.secret
|
||||
|
||||
externalRequestFilter:
|
||||
grpcMethods:
|
||||
- com.example.grpc.ExampleService/exampleMethod
|
||||
paths:
|
||||
- /example
|
||||
permittedInternalRanges:
|
||||
- 127.0.0.0/8
|
||||
|
||||
idlePrimaryDeviceReminder:
|
||||
minIdleDuration: P30D
|
||||
|
||||
grpc:
|
||||
port: 50051
|
||||
|
||||
asnTable:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: asn.tsv
|
||||
maxSize: 100000
|
||||
refreshInterval: PT10S
|
||||
|
||||
callQualitySurvey:
|
||||
pubSubPublisher:
|
||||
project: example-project
|
||||
topic: example-topic
|
||||
credentialConfiguration: |
|
||||
{
|
||||
"credential": "configuration"
|
||||
}
|
||||
|
||||
353
service/pom.xml
353
service/pom.xml
@@ -10,13 +10,49 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>service</artifactId>
|
||||
|
||||
<properties>
|
||||
<firebase-admin.version>9.6.0</firebase-admin.version>
|
||||
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
|
||||
<google-androidpublisher.version>v3-rev20250904-2.0.0</google-androidpublisher.version>
|
||||
<storekit.version>3.6.0</storekit.version>
|
||||
<webauthn4j.version>0.29.6.RELEASE</webauthn4j.version>
|
||||
<java-jwt.version>4.5.0</java-jwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-jaxrs2</artifactId>
|
||||
<version>2.2.8</version>
|
||||
<groupId>com.auth0</groupId>
|
||||
<artifactId>java-jwt</artifactId>
|
||||
<version>${java-jwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.apis</groupId>
|
||||
<artifactId>google-api-services-androidpublisher</artifactId>
|
||||
<version>${google-androidpublisher.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.apple.itunes.storekit</groupId>
|
||||
<artifactId>app-store-server-library</artifactId>
|
||||
<version>${storekit.version}</version>
|
||||
<exclusions>
|
||||
<!-- org.yaml:snakeyaml is causing a dependency convergence error -->
|
||||
<!-- conflicts with other users; resolved manually with explicit import -->
|
||||
<exclusion>
|
||||
<groupId>com.squareup.okio</groupId>
|
||||
<artifactId>okio-jvm</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.webauthn4j</groupId>
|
||||
<artifactId>webauthn4j-appattest</artifactId>
|
||||
<version>${webauthn4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-jaxrs2-jakarta</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<exclusions>
|
||||
<!-- conflicts with jackson-dataformat-yaml -->
|
||||
<exclusion>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
@@ -36,11 +72,6 @@
|
||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>event-logger</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>websocket-resources</artifactId>
|
||||
@@ -51,6 +82,11 @@
|
||||
<artifactId>libsignal-server</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>simple-grpc-runtime</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.dropwizard</groupId>
|
||||
<artifactId>dropwizard-core</artifactId>
|
||||
@@ -65,7 +101,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.dropwizard</groupId>
|
||||
<artifactId>dropwizard-db</artifactId>
|
||||
<artifactId>dropwizard-http2</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.dropwizard</groupId>
|
||||
@@ -114,8 +150,8 @@
|
||||
<artifactId>logback-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-access</artifactId>
|
||||
<groupId>ch.qos.logback.access</groupId>
|
||||
<artifactId>logback-access-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
@@ -150,19 +186,31 @@
|
||||
<groupId>org.glassfish.jersey.core</groupId>
|
||||
<artifactId>jersey-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jaxb</groupId>
|
||||
<artifactId>jaxb-runtime</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.dropwizard</groupId>
|
||||
<artifactId>dropwizard-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-sdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-exporter-otlp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry.instrumentation</groupId>
|
||||
<artifactId>opentelemetry-logback-appender-1.0</artifactId>
|
||||
<!-- *all* opentelemetry-logback-appender versions are "alpha" despite the advanced version number -->
|
||||
<version>2.19.0-alpha</version>
|
||||
<exclusions>
|
||||
<!-- incubator packages aren't included in the opentelemetry BOM, and we don't use them -->
|
||||
<exclusion>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<groupId>io.opentelemetry.instrumentation</groupId>
|
||||
<artifactId>opentelemetry-instrumentation-api-incubator</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
@@ -189,12 +237,17 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.websocket</groupId>
|
||||
<artifactId>websocket-api</artifactId>
|
||||
<artifactId>websocket-jetty-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-servlets</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.websocket</groupId>
|
||||
<artifactId>websocket-jetty-client</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
@@ -204,11 +257,52 @@
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-csv</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.28.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-pubsub</artifactId>
|
||||
<exclusions>
|
||||
<!-- our direct import of guava brings in a more recent version of failureaccess, so excluding it here -->
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>failureaccess</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.firebase</groupId>
|
||||
<artifactId>firebase-admin</artifactId>
|
||||
<version>9.1.1</version>
|
||||
<version>${firebase-admin.version}</version>
|
||||
<exclusions>
|
||||
<!-- our direct import of guava brings in a more recent version of failureaccess, so excluding it here -->
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>failureaccess</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-firestore</artifactId>
|
||||
<exclusions>
|
||||
<!-- incubator packages aren't included in the opentelemetry BOM, and we don't use them -->
|
||||
<exclusion>
|
||||
<groupId>io.opentelemetry.instrumentation</groupId>
|
||||
<artifactId>opentelemetry-instrumentation-api-incubator</artifactId>
|
||||
</exclusion>
|
||||
<!-- our direct import of guava brings in a more recent version of failureaccess, so excluding it here -->
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>failureaccess</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -231,8 +325,7 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-netty-shaded</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<artifactId>grpc-netty</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
@@ -255,11 +348,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-statsd</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.coursera</groupId>
|
||||
<artifactId>dropwizard-metrics-datadog</artifactId>
|
||||
<artifactId>micrometer-registry-otlp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
@@ -291,6 +380,19 @@
|
||||
<artifactId>reactor-grpc-stub</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.foundationdb</groupId>
|
||||
<artifactId>fdb-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>apache-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>netty-nio-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>sts</artifactId>
|
||||
@@ -303,24 +405,10 @@
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>dynamodb</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>appconfig</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>appconfigdata</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>dynamodb-lock-client</artifactId>
|
||||
<version>1.2.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>commons-logging</groupId>
|
||||
<artifactId>commons-logging</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
<version>1.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -357,11 +445,30 @@
|
||||
<artifactId>libphonenumber</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Provides tools for mapping phone numbers to time zones, which is helpful for scheduling push notifications
|
||||
during waking hours -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>geocoder</artifactId>
|
||||
<version>3.14</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.argparse4j</groupId>
|
||||
<artifactId>argparse4j</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-codec-haproxy</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-transport-native-epoll</artifactId>
|
||||
<classifier>linux-x86_64</classifier>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jersey.test-framework</groupId>
|
||||
<artifactId>jersey-test-framework-core</artifactId>
|
||||
@@ -377,23 +484,6 @@
|
||||
<groupId>org.glassfish.jersey.test-framework.providers</groupId>
|
||||
<artifactId>jersey-test-framework-provider-grizzly2</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.almworks.sqlite4java</groupId>
|
||||
<artifactId>sqlite4java</artifactId>
|
||||
<version>1.0.392</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -404,10 +494,6 @@
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core-micrometer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vavr</groupId>
|
||||
<artifactId>vavr</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
@@ -421,29 +507,33 @@
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>embedded-redis</artifactId>
|
||||
<groupId>com.redis</groupId>
|
||||
<artifactId>testcontainers-redis</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.uuid</groupId>
|
||||
<artifactId>java-uuid-generator</artifactId>
|
||||
<version>4.0.1</version>
|
||||
<version>${java-uuid-generator.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>DynamoDBLocal</artifactId>
|
||||
<version>1.23.0</version>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>localstack</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.github.ganadist.sqlite4java</groupId>
|
||||
<artifactId>libsqlite4java-osx-aarch64</artifactId>
|
||||
<version>1.0.392</version>
|
||||
<type>dylib</type>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>earth.adi</groupId>
|
||||
<artifactId>testcontainers-foundationdb</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -452,11 +542,6 @@
|
||||
<artifactId>google-auth-library-oauth2-http</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-recaptchaenterprise</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.stripe</groupId>
|
||||
<artifactId>stripe-java</artifactId>
|
||||
@@ -470,7 +555,26 @@
|
||||
<dependency>
|
||||
<groupId>com.apollographql.apollo3</groupId>
|
||||
<artifactId>apollo-api-jvm</artifactId>
|
||||
<version>3.7.1</version>
|
||||
<version>3.8.5</version>
|
||||
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
</exclusion>
|
||||
<!-- conflicts with other users; resolved manually with explicit import -->
|
||||
<exclusion>
|
||||
<groupId>com.squareup.okio</groupId>
|
||||
<artifactId>okio-jvm</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- to resolve conflicting imports from other dependencies -->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okio</groupId>
|
||||
<artifactId>okio-jvm</artifactId>
|
||||
<version>3.15.0</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
@@ -480,10 +584,31 @@
|
||||
<id>exclude-spam-filter</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>io.github.download-maven-plugin</groupId>
|
||||
<artifactId>download-maven-plugin</artifactId>
|
||||
<version>2.0.0</version>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install-foundationdb-client-library</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>wget</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
<configuration>
|
||||
<url>https://github.com/apple/foundationdb/releases/download/${foundationdb.version}/libfdb_c.x86_64.so</url>
|
||||
<outputDirectory>${project.build.directory}/jib-extra/usr/lib</outputDirectory>
|
||||
<sha256>${foundationdb.client-library-sha256}</sha256>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>true</createDependencyReducedPom>
|
||||
<filters>
|
||||
@@ -518,7 +643,6 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<descriptors>
|
||||
<descriptor>assembly.xml</descriptor>
|
||||
@@ -538,7 +662,6 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>properties-maven-plugin</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>read-deploy-configuration</id>
|
||||
@@ -567,6 +690,16 @@
|
||||
<configuration>
|
||||
<from>
|
||||
<image>eclipse-temurin@sha256:${docker.image.sha256}</image>
|
||||
<platforms>
|
||||
<platform>
|
||||
<architecture>amd64</architecture>
|
||||
<os>linux</os>
|
||||
</platform>
|
||||
<platform>
|
||||
<architecture>arm64</architecture>
|
||||
<os>linux</os>
|
||||
</platform>
|
||||
</platforms>
|
||||
</from>
|
||||
<to>
|
||||
<image>${docker.repo}:${project.version}</image>
|
||||
@@ -578,6 +711,7 @@
|
||||
<jvmFlag>-Djava.awt.headless=true</jvmFlag>
|
||||
<jvmFlag>-Djdk.nio.maxCachedBufferSize=262144</jvmFlag>
|
||||
<jvmFlag>-Dlog4j2.formatMsgNoLookups=true</jvmFlag>
|
||||
<jvmFlag>-Djdk.tls.server.newSessionTicketCount=0</jvmFlag>
|
||||
<jvmFlag>-XX:MaxRAMPercentage=75</jvmFlag>
|
||||
<jvmFlag>-XX:+HeapDumpOnOutOfMemoryError</jvmFlag>
|
||||
<jvmFlag>-XX:HeapDumpPath=/tmp/heapdump.bin</jvmFlag>
|
||||
@@ -594,6 +728,9 @@
|
||||
<includes>*.yml</includes>
|
||||
<into>/usr/share/signal/</into>
|
||||
</path>
|
||||
<path>
|
||||
<from>${project.build.directory}/jib-extra</from>
|
||||
</path>
|
||||
</paths>
|
||||
</extraDirectories>
|
||||
</configuration>
|
||||
@@ -616,6 +753,30 @@
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
<profile>
|
||||
<id>test-server</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>start-test-server</id>
|
||||
<phase>integration-test</phase>
|
||||
<goals>
|
||||
<goal>java</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<mainClass>org.whispersystems.textsecuregcm.LocalWhisperServerService</mainClass>
|
||||
<classpathScope>test</classpathScope>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
@@ -624,7 +785,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>templating-maven-plugin</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<version>3.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>filter-src</id>
|
||||
@@ -632,16 +793,21 @@
|
||||
<goal>filter-sources</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>filter-test-src</id>
|
||||
<goals>
|
||||
<goal>filter-test-sources</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M7</version>
|
||||
<configuration>
|
||||
<!-- work around PATCH not being a supported method on HttpUrlConnection -->
|
||||
<argLine>--add-opens=java.base/java.net=ALL-UNNAMED</argLine>
|
||||
<!-- add-opens: work around PATCH not being a supported method on HttpUrlConnection -->
|
||||
<argLine>-javaagent:${org.mockito:mockito-core:jar} --add-opens=java.base/java.net=ALL-UNNAMED</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
@@ -660,7 +826,6 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>check-all-service-config</id>
|
||||
@@ -668,15 +833,15 @@
|
||||
<goals>
|
||||
<goal>java</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<mainClass>org.whispersystems.textsecuregcm.CheckServiceConfigurations</mainClass>
|
||||
<classpathScope>test</classpathScope>
|
||||
<arguments>
|
||||
<argument>${project.basedir}/config</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<mainClass>org.whispersystems.textsecuregcm.CheckServiceConfigurations</mainClass>
|
||||
<classpathScope>test</classpathScope>
|
||||
<arguments>
|
||||
<argument>${project.basedir}/config</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
public class FoundationDbVersion {
|
||||
|
||||
private static final String VERSION = "${foundationdb.version}";
|
||||
private static final int API_VERSION = ${foundationdb.api-version};
|
||||
|
||||
public static String getFoundationDbVersion() {
|
||||
return VERSION;
|
||||
}
|
||||
|
||||
public static int getFoundationDbApiVersion() {
|
||||
return API_VERSION;
|
||||
}
|
||||
}
|
||||
@@ -5,59 +5,66 @@
|
||||
package org.whispersystems.textsecuregcm;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.dropwizard.Configuration;
|
||||
import io.dropwizard.core.Configuration;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppleAppStoreConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppleDeviceCheckConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CallQualitySurveyConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ClientReleaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CommandStopListenerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DogstatsdConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DefaultAwsCredentialsFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.DeviceCheckConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
||||
import org.whispersystems.textsecuregcm.configuration.ExternalRequestFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClientFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
|
||||
import org.whispersystems.textsecuregcm.configuration.HCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GrpcConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OpenTelemetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.PagedSingleUseKEMPreKeyStoreConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RegistrationServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RegistrationServiceClientFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TurnSecretConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TlsKeyStoreConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TurnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.VirtualThreadConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiterConfig;
|
||||
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
||||
|
||||
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
|
||||
@@ -66,7 +73,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AdminEventLoggingConfiguration adminEventLoggingConfiguration;
|
||||
private TlsKeyStoreConfiguration tlsKeyStore;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
AwsCredentialsProviderFactory awsCredentialsProvider = new DefaultAwsCredentialsFactory();
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@@ -81,18 +93,33 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private DynamoDbClientConfiguration dynamoDbClientConfiguration;
|
||||
private GooglePlayBillingConfiguration googlePlayBilling;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AppleAppStoreConfiguration appleAppStore;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AppleDeviceCheckConfiguration appleDeviceCheck;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private DeviceCheckConfiguration deviceCheck;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private DynamoDbClientFactory dynamoDbClient;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private DynamoDbTables dynamoDbTables;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AwsAttachmentsConfiguration awsAttachments;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -106,22 +133,22 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private DogstatsdConfiguration dogstatsd = new DogstatsdConfiguration();
|
||||
private Cdn3StorageManagerConfiguration cdn3StorageManager;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RedisClusterConfiguration cacheCluster;
|
||||
private OpenTelemetryConfiguration openTelemetry;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RedisConfiguration pubsub;
|
||||
private FaultTolerantRedisClusterFactory cacheCluster;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RedisClusterConfiguration metricsCluster;
|
||||
private FaultTolerantRedisClientFactory pubsub;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@@ -131,48 +158,33 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private SecureValueRecovery2Configuration svr2;
|
||||
private SecureValueRecoveryConfiguration svr2;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AccountDatabaseCrawlerConfiguration accountDatabaseCrawler;
|
||||
private SecureValueRecoveryConfiguration svrb;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RedisClusterConfiguration pushSchedulerCluster;
|
||||
private FaultTolerantRedisClusterFactory pushSchedulerCluster;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RedisClusterConfiguration rateLimitersCluster;
|
||||
private FaultTolerantRedisClusterFactory rateLimitersCluster;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private MessageCacheConfiguration messageCache;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RedisClusterConfiguration clientPresenceCluster;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private Set<String> testDevices = new HashSet<>();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private List<MaxDeviceConfiguration> maxDevices = new LinkedList<>();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private Map<String, RateLimiterConfig> limits = new HashMap<>();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -193,16 +205,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private RecaptchaConfiguration recaptcha;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private HCaptchaConfiguration hCaptcha;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -213,21 +215,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private SecureStorageServiceConfiguration storageService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private SecureBackupServiceConfiguration backupService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private PaymentsServiceConfiguration paymentsService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private ArtServiceConfiguration artService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -236,7 +228,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private GenericZkConfig genericZkConfig;
|
||||
private GenericZkConfig callingZkConfig;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private GenericZkConfig backupsZkConfig;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -246,7 +243,7 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private AppConfigConfiguration appConfig;
|
||||
private S3ObjectMonitorFactory dynamicConfig;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -263,6 +260,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
private OneTimeDonationConfiguration oneTimeDonations;
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private PagedSingleUseKEMPreKeyStoreConfiguration pagedSingleUseKEMPreKeyStore;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -270,28 +272,23 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private SpamFilterConfiguration spamFilterConfiguration;
|
||||
private SpamFilterConfiguration spamFilter;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private RegistrationServiceConfiguration registrationService;
|
||||
private RegistrationServiceClientFactory registrationService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private TurnSecretConfiguration turn;
|
||||
private TurnConfiguration turn;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private TusConfiguration tus;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private int grpcPort;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -305,15 +302,62 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private CommandStopListenerConfiguration commandStopListener;
|
||||
private LinkDeviceSecretConfiguration linkDevice;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private LinkDeviceSecretConfiguration linkDevice;
|
||||
private VirtualThreadConfiguration virtualThread = new VirtualThreadConfiguration();
|
||||
|
||||
public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() {
|
||||
return adminEventLoggingConfiguration;
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private ExternalRequestFilterConfiguration externalRequestFilter;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private KeyTransparencyServiceConfiguration keyTransparencyService;
|
||||
|
||||
@JsonProperty
|
||||
private boolean logMessageDeliveryLoops;
|
||||
|
||||
@JsonProperty
|
||||
private IdlePrimaryDeviceReminderConfiguration idlePrimaryDeviceReminder =
|
||||
new IdlePrimaryDeviceReminderConfiguration(Duration.ofDays(30));
|
||||
|
||||
@JsonProperty
|
||||
private Map<String, @Valid CircuitBreakerConfiguration> circuitBreakers = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
private Map<String, @Valid RetryConfiguration> retries = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
@NotNull
|
||||
private RetryConfiguration generalRedisRetry = new RetryConfiguration();
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private GrpcConfiguration grpc;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private S3ObjectMonitorFactory asnTable;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private CallQualitySurveyConfiguration callQualitySurvey;
|
||||
|
||||
public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() {
|
||||
return tlsKeyStore;
|
||||
}
|
||||
|
||||
public AwsCredentialsProviderFactory getAwsCredentialsConfiguration() {
|
||||
return awsCredentialsProvider;
|
||||
}
|
||||
|
||||
public StripeConfiguration getStripe() {
|
||||
@@ -324,22 +368,30 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return braintree;
|
||||
}
|
||||
|
||||
public DynamoDbClientConfiguration getDynamoDbClientConfiguration() {
|
||||
return dynamoDbClientConfiguration;
|
||||
public GooglePlayBillingConfiguration getGooglePlayBilling() {
|
||||
return googlePlayBilling;
|
||||
}
|
||||
|
||||
public AppleAppStoreConfiguration getAppleAppStore() {
|
||||
return appleAppStore;
|
||||
}
|
||||
|
||||
public AppleDeviceCheckConfiguration getAppleDeviceCheck() {
|
||||
return appleDeviceCheck;
|
||||
}
|
||||
|
||||
public DeviceCheckConfiguration getDeviceCheck() {
|
||||
return deviceCheck;
|
||||
}
|
||||
|
||||
public DynamoDbClientFactory getDynamoDbClientConfiguration() {
|
||||
return dynamoDbClient;
|
||||
}
|
||||
|
||||
public DynamoDbTables getDynamoDbTables() {
|
||||
return dynamoDbTables;
|
||||
}
|
||||
|
||||
public RecaptchaConfiguration getRecaptchaConfiguration() {
|
||||
return recaptcha;
|
||||
}
|
||||
|
||||
public HCaptchaConfiguration getHCaptchaConfiguration() {
|
||||
return hCaptcha;
|
||||
}
|
||||
|
||||
public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {
|
||||
return shortCode;
|
||||
}
|
||||
@@ -348,28 +400,24 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return webSocket;
|
||||
}
|
||||
|
||||
public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() {
|
||||
return awsAttachments;
|
||||
}
|
||||
|
||||
public GcpAttachmentsConfiguration getGcpAttachmentsConfiguration() {
|
||||
return gcpAttachments;
|
||||
}
|
||||
|
||||
public RedisClusterConfiguration getCacheClusterConfiguration() {
|
||||
public FaultTolerantRedisClusterFactory getCacheClusterConfiguration() {
|
||||
return cacheCluster;
|
||||
}
|
||||
|
||||
public RedisConfiguration getPubsubCacheConfiguration() {
|
||||
public FaultTolerantRedisClientFactory getRedisPubSubConfiguration() {
|
||||
return pubsub;
|
||||
}
|
||||
|
||||
public RedisClusterConfiguration getMetricsClusterConfiguration() {
|
||||
return metricsCluster;
|
||||
public SecureValueRecoveryConfiguration getSvr2Configuration() {
|
||||
return svr2;
|
||||
}
|
||||
|
||||
public SecureValueRecovery2Configuration getSvr2Configuration() {
|
||||
return svr2;
|
||||
public SecureValueRecoveryConfiguration getSvrbConfiguration() {
|
||||
return svrb;
|
||||
}
|
||||
|
||||
public DirectoryV2Configuration getDirectoryV2Configuration() {
|
||||
@@ -380,30 +428,18 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return storageService;
|
||||
}
|
||||
|
||||
public AccountDatabaseCrawlerConfiguration getAccountDatabaseCrawlerConfiguration() {
|
||||
return accountDatabaseCrawler;
|
||||
}
|
||||
|
||||
public MessageCacheConfiguration getMessageCacheConfiguration() {
|
||||
return messageCache;
|
||||
}
|
||||
|
||||
public RedisClusterConfiguration getClientPresenceClusterConfiguration() {
|
||||
return clientPresenceCluster;
|
||||
}
|
||||
|
||||
public RedisClusterConfiguration getPushSchedulerCluster() {
|
||||
public FaultTolerantRedisClusterFactory getPushSchedulerCluster() {
|
||||
return pushSchedulerCluster;
|
||||
}
|
||||
|
||||
public RedisClusterConfiguration getRateLimitersCluster() {
|
||||
public FaultTolerantRedisClusterFactory getRateLimitersCluster() {
|
||||
return rateLimitersCluster;
|
||||
}
|
||||
|
||||
public Map<String, RateLimiterConfig> getLimitsConfiguration() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
public FcmConfiguration getFcmConfiguration() {
|
||||
return fcm;
|
||||
}
|
||||
@@ -416,18 +452,18 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return cdn;
|
||||
}
|
||||
|
||||
public DogstatsdConfiguration getDatadogConfiguration() {
|
||||
return dogstatsd;
|
||||
public Cdn3StorageManagerConfiguration getCdn3StorageManagerConfiguration() {
|
||||
return cdn3StorageManager;
|
||||
}
|
||||
|
||||
public OpenTelemetryConfiguration getOpenTelemetryConfiguration() {
|
||||
return openTelemetry;
|
||||
}
|
||||
|
||||
public UnidentifiedDeliveryConfiguration getDeliveryCertificate() {
|
||||
return unidentifiedDelivery;
|
||||
}
|
||||
|
||||
public Set<String> getTestDevices() {
|
||||
return testDevices;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getMaxDevices() {
|
||||
Map<String, Integer> results = new HashMap<>();
|
||||
|
||||
@@ -439,32 +475,28 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return results;
|
||||
}
|
||||
|
||||
public SecureBackupServiceConfiguration getSecureBackupServiceConfiguration() {
|
||||
return backupService;
|
||||
}
|
||||
|
||||
public PaymentsServiceConfiguration getPaymentsServiceConfiguration() {
|
||||
return paymentsService;
|
||||
}
|
||||
|
||||
public ArtServiceConfiguration getArtServiceConfiguration() {
|
||||
return artService;
|
||||
}
|
||||
|
||||
public ZkConfig getZkConfig() {
|
||||
return zkConfig;
|
||||
}
|
||||
|
||||
public GenericZkConfig getGenericZkConfig() {
|
||||
return genericZkConfig;
|
||||
public GenericZkConfig getCallingZkConfig() {
|
||||
return callingZkConfig;
|
||||
}
|
||||
|
||||
public GenericZkConfig getBackupsZkConfig() {
|
||||
return backupsZkConfig;
|
||||
}
|
||||
|
||||
public RemoteConfigConfiguration getRemoteConfigConfiguration() {
|
||||
return remoteConfig;
|
||||
}
|
||||
|
||||
public AppConfigConfiguration getAppConfig() {
|
||||
return appConfig;
|
||||
public S3ObjectMonitorFactory getDynamicConfig() {
|
||||
return dynamicConfig;
|
||||
}
|
||||
|
||||
public BadgesConfiguration getBadges() {
|
||||
@@ -479,19 +511,23 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return oneTimeDonations;
|
||||
}
|
||||
|
||||
public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {
|
||||
return pagedSingleUseKEMPreKeyStore;
|
||||
}
|
||||
|
||||
public ReportMessageConfiguration getReportMessageConfiguration() {
|
||||
return reportMessage;
|
||||
}
|
||||
|
||||
public SpamFilterConfiguration getSpamFilterConfiguration() {
|
||||
return spamFilterConfiguration;
|
||||
return spamFilter;
|
||||
}
|
||||
|
||||
public RegistrationServiceConfiguration getRegistrationServiceConfiguration() {
|
||||
public RegistrationServiceClientFactory getRegistrationServiceConfiguration() {
|
||||
return registrationService;
|
||||
}
|
||||
|
||||
public TurnSecretConfiguration getTurnSecretConfiguration() {
|
||||
public TurnConfiguration getTurnConfiguration() {
|
||||
return turn;
|
||||
}
|
||||
|
||||
@@ -499,10 +535,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return tus;
|
||||
}
|
||||
|
||||
public int getGrpcPort() {
|
||||
return grpcPort;
|
||||
}
|
||||
|
||||
public ClientReleaseConfiguration getClientReleaseConfiguration() {
|
||||
return clientRelease;
|
||||
}
|
||||
@@ -511,11 +543,51 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return messageByteLimitCardinalityEstimator;
|
||||
}
|
||||
|
||||
public CommandStopListenerConfiguration getCommandStopListener() {
|
||||
return commandStopListener;
|
||||
}
|
||||
|
||||
public LinkDeviceSecretConfiguration getLinkDeviceSecretConfiguration() {
|
||||
return linkDevice;
|
||||
}
|
||||
|
||||
public VirtualThreadConfiguration getVirtualThreadConfiguration() {
|
||||
return virtualThread;
|
||||
}
|
||||
|
||||
public ExternalRequestFilterConfiguration getExternalRequestFilterConfiguration() {
|
||||
return externalRequestFilter;
|
||||
}
|
||||
|
||||
public KeyTransparencyServiceConfiguration getKeyTransparencyServiceConfiguration() {
|
||||
return keyTransparencyService;
|
||||
}
|
||||
|
||||
public boolean logMessageDeliveryLoops() {
|
||||
return logMessageDeliveryLoops;
|
||||
}
|
||||
|
||||
public IdlePrimaryDeviceReminderConfiguration idlePrimaryDeviceReminderConfiguration() {
|
||||
return idlePrimaryDeviceReminder;
|
||||
}
|
||||
|
||||
public Map<String, CircuitBreakerConfiguration> getCircuitBreakerConfigurations() {
|
||||
return circuitBreakers;
|
||||
}
|
||||
|
||||
public Map<String, RetryConfiguration> getRetryConfigurations() {
|
||||
return retries;
|
||||
}
|
||||
|
||||
public RetryConfiguration getGeneralRedisRetryConfiguration() {
|
||||
return generalRedisRetry;
|
||||
}
|
||||
|
||||
public GrpcConfiguration getGrpc() {
|
||||
return grpc;
|
||||
}
|
||||
|
||||
public S3ObjectMonitorFactory getAsnTableConfiguration() {
|
||||
return asnTable;
|
||||
}
|
||||
|
||||
public CallQualitySurveyConfiguration getCallQualitySurveyConfiguration() {
|
||||
return callQualitySurvey;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.asn;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public record AsnInfo(long asn, @Nonnull String regionCode) {
|
||||
|
||||
public AsnInfo {
|
||||
requireNonNull(regionCode, "regionCode must not be null");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.asn;
|
||||
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public interface AsnInfoProvider {
|
||||
|
||||
/// Gets ASN information for an IP address.
|
||||
///
|
||||
/// @param ipString a string representation of an IP address
|
||||
///
|
||||
/// @return ASN information for the given IP address or empty if no ASN information was found for the given IP address
|
||||
Optional<AsnInfo> lookup(@Nonnull String ipString);
|
||||
|
||||
AsnInfoProvider EMPTY = _ -> Optional.empty();
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.asn;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.math.BigInteger;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import javax.annotation.Nonnull;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* {@code AsnInfoProvider} implementation that supports both IPv4 and IPv6.
|
||||
*/
|
||||
public class AsnInfoProviderImpl implements AsnInfoProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
|
||||
|
||||
@Nonnull
|
||||
private final NavigableMap<Long, AsnRange<Long>> asnBlocksByFirstIpv4;
|
||||
|
||||
@Nonnull
|
||||
private final NavigableMap<BigInteger, AsnRange<BigInteger>> asnBlocksByFirstIpv6;
|
||||
|
||||
|
||||
/**
|
||||
* Creates an instance of {@code AsnInfoProviderImpl} using data from <a href="https://iptoasn.com/">iptoasn.com</a>.
|
||||
* @param tsvGzInputStream gzip input stream representing the data.
|
||||
*/
|
||||
@Nonnull
|
||||
public static AsnInfoProviderImpl fromTsvGz(@Nonnull final InputStream tsvGzInputStream) {
|
||||
try (final GZIPInputStream inputStream = new GZIPInputStream(tsvGzInputStream)) {
|
||||
return fromTsv(inputStream);
|
||||
} catch (final IOException e) {
|
||||
log.error("failed to ungzip the input stream", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of {@code AsnInfoProviderImpl} using data from <a href="https://iptoasn.com/">iptoasn.com</a>.
|
||||
* @param tsvInputStream input stream representing the data.
|
||||
*/
|
||||
@Nonnull
|
||||
public static AsnInfoProviderImpl fromTsv(@Nonnull final InputStream tsvInputStream) {
|
||||
try (final InputStreamReader tsvReader = new InputStreamReader(tsvInputStream)) {
|
||||
final NavigableMap<Long, AsnRange<Long>> ip4asns = new TreeMap<>();
|
||||
final NavigableMap<BigInteger, AsnRange<BigInteger>> ip6asns = new TreeMap<>();
|
||||
final Map<Long, AsnInfo> asnInfoCache = new HashMap<>();
|
||||
|
||||
try (final CSVParser csvParser = CSVFormat.TDF.parse(tsvReader)) {
|
||||
for (final CSVRecord record : csvParser) {
|
||||
// format:
|
||||
// range_start_ip_string range_end_ip_string AS_number country_code AS_description
|
||||
final InetAddress startIp = InetAddress.getByName(record.get(0));
|
||||
final InetAddress endIp = InetAddress.getByName(record.get(1));
|
||||
final long asn = Long.parseLong(record.get(2));
|
||||
final String regionCode = record.get(3);
|
||||
// country code should be the same for any ASN, so we're caching AsnInfo objects
|
||||
// not to have multiple instances with the same values
|
||||
final AsnInfo asnInfo = asnInfoCache.computeIfAbsent(asn, k -> new AsnInfo(asn, regionCode));
|
||||
if (!regionCode.equals(asnInfo.regionCode())) {
|
||||
log.warn("ASN {} mapped to country codes {} and {}", asn, regionCode, asnInfo.regionCode());
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (startIp instanceof Inet4Address) {
|
||||
final AsnRange<Long> asnRange = new AsnRange<>(
|
||||
ip4BytesToLong((Inet4Address) startIp),
|
||||
ip4BytesToLong((Inet4Address) endIp),
|
||||
asnInfo
|
||||
);
|
||||
ip4asns.put(asnRange.from(), asnRange);
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if (startIp instanceof Inet6Address) {
|
||||
final AsnRange<BigInteger> asnRange = new AsnRange<>(
|
||||
ip6BytesToBigInteger((Inet6Address) startIp),
|
||||
ip6BytesToBigInteger((Inet6Address) endIp),
|
||||
asnInfo
|
||||
);
|
||||
ip6asns.put(asnRange.from(), asnRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new AsnInfoProviderImpl(ip4asns, ip6asns);
|
||||
} catch (final Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public AsnInfoProviderImpl(
|
||||
@Nonnull final NavigableMap<Long, AsnRange<Long>> asnBlocksByFirstIpv4,
|
||||
@Nonnull final NavigableMap<BigInteger, AsnRange<BigInteger>> asnBlocksByFirstIpv6) {
|
||||
this.asnBlocksByFirstIpv4 = requireNonNull(asnBlocksByFirstIpv4);
|
||||
this.asnBlocksByFirstIpv6 = requireNonNull(asnBlocksByFirstIpv6);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Optional<AsnInfo> lookup(@Nonnull final String ipString) {
|
||||
try {
|
||||
final InetAddress address = InetAddress.getByName(ipString);
|
||||
if (address instanceof Inet4Address ip4) {
|
||||
final Long key = ip4BytesToLong(ip4);
|
||||
return lookupInMap(asnBlocksByFirstIpv4, key);
|
||||
}
|
||||
if (address instanceof Inet6Address ip6) {
|
||||
final BigInteger key = ip6BytesToBigInteger(ip6);
|
||||
return lookupInMap(asnBlocksByFirstIpv6, key);
|
||||
}
|
||||
// safety net, should never happen
|
||||
log.warn("Unknown InetAddress implementation: {}", address.getClass().getName());
|
||||
} catch (final Exception e) {
|
||||
log.error("Could not resolve ASN for IP string {}", ipString);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
protected static long ip4BytesToLong(@Nonnull final Inet4Address address) {
|
||||
final byte[] arr = address.getAddress();
|
||||
Validate.isTrue(arr.length == 4);
|
||||
return Integer.toUnsignedLong(ByteBuffer.wrap(arr).getInt());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
protected static BigInteger ip6BytesToBigInteger(@Nonnull final Inet6Address address) {
|
||||
final byte[] arr = address.getAddress();
|
||||
Validate.isTrue(arr.length == 16);
|
||||
return new BigInteger(1, arr);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static <T extends Comparable<T>> Optional<AsnInfo> lookupInMap(
|
||||
@Nonnull final NavigableMap<T, AsnRange<T>> map,
|
||||
@Nonnull final T key) {
|
||||
return Optional.ofNullable(map.floorEntry(key))
|
||||
.filter(e -> e.getValue().contains(key) && e.getValue().asnInfo().asn() != 0)
|
||||
.map(e -> e.getValue().asnInfo());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.asn;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
|
||||
public record AsnRange<T extends Comparable<T>>(@Nonnull T from,
|
||||
@Nonnull T to,
|
||||
@Nonnull AsnInfo asnInfo) {
|
||||
public AsnRange {
|
||||
requireNonNull(from);
|
||||
requireNonNull(to);
|
||||
requireNonNull(asnInfo);
|
||||
Validate.isTrue(from.compareTo(to) <= 0);
|
||||
}
|
||||
|
||||
boolean contains(@Nonnull final T element) {
|
||||
requireNonNull(element);
|
||||
return from.compareTo(element) <= 0
|
||||
&& element.compareTo(to) <= 0;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.attachments;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public record TusConfiguration(
|
||||
@ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public interface AccountAndAuthenticatedDeviceHolder {
|
||||
|
||||
Account getAccount();
|
||||
|
||||
Device getAuthenticatedDevice();
|
||||
}
|
||||
@@ -1,25 +1,161 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Authenticator;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import java.util.UUID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class AccountAuthenticator extends BaseAccountAuthenticator implements
|
||||
Authenticator<BasicCredentials, AuthenticatedAccount> {
|
||||
public class AccountAuthenticator implements Authenticator<BasicCredentials, AuthenticatedDevice> {
|
||||
|
||||
private static final String AUTHENTICATION_COUNTER_NAME = name(AccountAuthenticator.class, "authentication");
|
||||
private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = "succeeded";
|
||||
private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason";
|
||||
|
||||
private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(AccountAuthenticator.class, "daysSinceLastSeen");
|
||||
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";
|
||||
|
||||
private static final Counter OLD_TOKEN_VERSION_COUNTER =
|
||||
Metrics.counter(name(AccountAuthenticator.class, "oldTokenVersionCounter"));
|
||||
|
||||
@VisibleForTesting
|
||||
static final char DEVICE_ID_SEPARATOR = '.';
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final Clock clock;
|
||||
|
||||
public AccountAuthenticator(AccountsManager accountsManager) {
|
||||
super(accountsManager);
|
||||
this(accountsManager, Clock.systemUTC());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public AccountAuthenticator(AccountsManager accountsManager, Clock clock) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
static Pair<String, Byte> getIdentifierAndDeviceId(final String basicUsername) {
|
||||
final String identifier;
|
||||
final byte deviceId;
|
||||
|
||||
final int deviceIdSeparatorIndex = basicUsername.indexOf(DEVICE_ID_SEPARATOR);
|
||||
|
||||
if (deviceIdSeparatorIndex == -1) {
|
||||
identifier = basicUsername;
|
||||
deviceId = Device.PRIMARY_ID;
|
||||
} else {
|
||||
identifier = basicUsername.substring(0, deviceIdSeparatorIndex);
|
||||
deviceId = Byte.parseByte(basicUsername.substring(deviceIdSeparatorIndex + 1));
|
||||
}
|
||||
|
||||
return new Pair<>(identifier, deviceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthenticatedAccount> authenticate(BasicCredentials basicCredentials) {
|
||||
return super.authenticate(basicCredentials, true);
|
||||
public Optional<AuthenticatedDevice> authenticate(BasicCredentials basicCredentials) {
|
||||
boolean succeeded = false;
|
||||
String failureReason = null;
|
||||
|
||||
try {
|
||||
final UUID accountUuid;
|
||||
final byte deviceId;
|
||||
{
|
||||
final Pair<String, Byte> identifierAndDeviceId = getIdentifierAndDeviceId(basicCredentials.getUsername());
|
||||
|
||||
accountUuid = UUID.fromString(identifierAndDeviceId.first());
|
||||
deviceId = identifierAndDeviceId.second();
|
||||
}
|
||||
|
||||
Optional<Account> account = accountsManager.getByAccountIdentifier(accountUuid);
|
||||
|
||||
if (account.isEmpty()) {
|
||||
failureReason = "noSuchAccount";
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<Device> device = account.get().getDevice(deviceId);
|
||||
|
||||
if (device.isEmpty()) {
|
||||
failureReason = "noSuchDevice";
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
SaltedTokenHash deviceSaltedTokenHash = device.get().getAuthTokenHash();
|
||||
if (deviceSaltedTokenHash.verify(basicCredentials.getPassword())) {
|
||||
succeeded = true;
|
||||
Account authenticatedAccount = updateLastSeen(account.get(), device.get());
|
||||
if (deviceSaltedTokenHash.getVersion() != SaltedTokenHash.CURRENT_VERSION) {
|
||||
OLD_TOKEN_VERSION_COUNTER.increment();
|
||||
authenticatedAccount = accountsManager.updateDeviceAuthentication(
|
||||
authenticatedAccount,
|
||||
device.get(),
|
||||
SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version
|
||||
}
|
||||
return Optional.of(new AuthenticatedDevice(authenticatedAccount.getIdentifier(IdentityType.ACI),
|
||||
device.get().getId(),
|
||||
Instant.ofEpochMilli(authenticatedAccount.getPrimaryDevice().getLastSeen())));
|
||||
} else {
|
||||
failureReason = "incorrectPassword";
|
||||
return Optional.empty();
|
||||
}
|
||||
} catch (IllegalArgumentException | InvalidAuthorizationHeaderException iae) {
|
||||
failureReason = "invalidHeader";
|
||||
return Optional.empty();
|
||||
} finally {
|
||||
Tags tags = Tags.of(
|
||||
AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded));
|
||||
|
||||
if (StringUtils.isNotBlank(failureReason)) {
|
||||
tags = tags.and(AUTHENTICATION_FAILURE_REASON_TAG_NAME, failureReason);
|
||||
}
|
||||
|
||||
Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public Account updateLastSeen(Account account, Device device) {
|
||||
// compute a non-negative integer between 0 and 86400.
|
||||
long n = Util.ensureNonNegativeLong(account.getUuid().getLeastSignificantBits());
|
||||
final long lastSeenOffsetSeconds = n % ChronoUnit.DAYS.getDuration().toSeconds();
|
||||
|
||||
// produce a truncated timestamp which is either today at UTC midnight
|
||||
// or yesterday at UTC midnight, based on per-user randomized offset used.
|
||||
final long todayInMillisWithOffset = Util.todayInMillisGivenOffsetFromNow(clock,
|
||||
Duration.ofSeconds(lastSeenOffsetSeconds).negated());
|
||||
|
||||
// only update the device's last seen time when it falls behind the truncated timestamp.
|
||||
// this ensures a few things:
|
||||
// (1) each account will only update last-seen at most once per day
|
||||
// (2) these updates will occur throughout the day rather than all occurring at UTC midnight.
|
||||
if (device.getLastSeen() < todayInMillisWithOffset) {
|
||||
Metrics.summary(DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME, IS_PRIMARY_DEVICE_TAG, String.valueOf(device.isPrimary()))
|
||||
.record(Duration.ofMillis(todayInMillisWithOffset - device.getLastSeen()).toDays());
|
||||
|
||||
return accountsManager.updateDeviceLastSeen(account, device, Util.todayInMillis(clock));
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.Base64;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
||||
public class Anonymous {
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
/**
|
||||
* This {@link WebsocketRefreshRequirementProvider} observes intra-request changes in {@link Account#isEnabled()} and
|
||||
* {@link Device#isEnabled()}.
|
||||
* <p>
|
||||
* If a change in {@link Account#isEnabled()} or any associated {@link Device#isEnabled()} is observed, then any active
|
||||
* WebSocket connections for the account must be closed in order for clients to get a refreshed
|
||||
* {@link io.dropwizard.auth.Auth} object with a current device list.
|
||||
*
|
||||
* @see AuthenticatedAccount
|
||||
* @see DisabledPermittedAuthenticatedAccount
|
||||
*/
|
||||
public class AuthEnablementRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthEnablementRefreshRequirementProvider.class);
|
||||
|
||||
private static final String ACCOUNT_UUID = AuthEnablementRefreshRequirementProvider.class.getName() + ".accountUuid";
|
||||
private static final String DEVICES_ENABLED = AuthEnablementRefreshRequirementProvider.class.getName() + ".devicesEnabled";
|
||||
|
||||
public AuthEnablementRefreshRequirementProvider(final AccountsManager accountsManager) {
|
||||
this.accountsManager = accountsManager;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static Map<Long, Boolean> buildDevicesEnabledMap(final Account account) {
|
||||
return account.getDevices().stream().collect(Collectors.toMap(Device::getId, Device::isEnabled));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRequestFiltered(final RequestEvent requestEvent) {
|
||||
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod().getAnnotation(ChangesDeviceEnabledState.class) != null) {
|
||||
// The authenticated principal, if any, will be available after filters have run.
|
||||
// Now that the account is known, capture a snapshot of `isEnabled` for the account's devices before carrying out
|
||||
// the request’s business logic.
|
||||
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest()).ifPresent(account ->
|
||||
setAccount(requestEvent.getContainerRequest(), account));
|
||||
}
|
||||
}
|
||||
|
||||
public static void setAccount(final ContainerRequest containerRequest, final Account account) {
|
||||
containerRequest.setProperty(ACCOUNT_UUID, account.getUuid());
|
||||
containerRequest.setProperty(DEVICES_ENABLED, buildDevicesEnabledMap(account));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<UUID, Long>> handleRequestFinished(final RequestEvent requestEvent) {
|
||||
// Now that the request is finished, check whether `isEnabled` changed for any of the devices. If the value did
|
||||
// change or if a devices was added or removed, all devices must disconnect and reauthenticate.
|
||||
if (requestEvent.getContainerRequest().getProperty(DEVICES_ENABLED) != null) {
|
||||
|
||||
@SuppressWarnings("unchecked") final Map<Long, Boolean> initialDevicesEnabled =
|
||||
(Map<Long, Boolean>) requestEvent.getContainerRequest().getProperty(DEVICES_ENABLED);
|
||||
|
||||
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID)).map(account -> {
|
||||
final Set<Long> deviceIdsToDisplace;
|
||||
final Map<Long, Boolean> currentDevicesEnabled = buildDevicesEnabledMap(account);
|
||||
|
||||
if (!initialDevicesEnabled.equals(currentDevicesEnabled)) {
|
||||
deviceIdsToDisplace = new HashSet<>(initialDevicesEnabled.keySet());
|
||||
deviceIdsToDisplace.addAll(currentDevicesEnabled.keySet());
|
||||
} else {
|
||||
deviceIdsToDisplace = Collections.emptySet();
|
||||
}
|
||||
|
||||
return deviceIdsToDisplace.stream()
|
||||
.map(deviceId -> new Pair<>(account.getUuid(), deviceId))
|
||||
.collect(Collectors.toList());
|
||||
}).orElseGet(() -> {
|
||||
logger.error("Request had account, but it is no longer present");
|
||||
return Collections.emptyList();
|
||||
});
|
||||
} else
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.function.Supplier;
|
||||
import javax.security.auth.Subject;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
public class AuthenticatedAccount implements Principal, AccountAndAuthenticatedDeviceHolder {
|
||||
|
||||
private final Supplier<Pair<Account, Device>> accountAndDevice;
|
||||
|
||||
public AuthenticatedAccount(final Supplier<Pair<Account, Device>> accountAndDevice) {
|
||||
this.accountAndDevice = accountAndDevice;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Account getAccount() {
|
||||
return accountAndDevice.get().first();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Device getAuthenticatedDevice() {
|
||||
return accountAndDevice.get().second();
|
||||
}
|
||||
|
||||
// Principal implementation
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean implies(final Subject subject) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record AuthenticatedBackupUser(
|
||||
byte[] backupId,
|
||||
BackupCredentialType credentialType,
|
||||
BackupLevel backupLevel,
|
||||
String backupDir,
|
||||
String mediaDir,
|
||||
@Nullable UserAgent userAgent) {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import javax.security.auth.Subject;
|
||||
|
||||
public record AuthenticatedDevice(UUID accountIdentifier, byte deviceId, Instant primaryDeviceLastSeen)
|
||||
implements Principal {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean implies(final Subject subject) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.RefreshingAccountAndDeviceSupplier;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class BaseAccountAuthenticator {
|
||||
|
||||
private static final String AUTHENTICATION_COUNTER_NAME = name(BaseAccountAuthenticator.class, "authentication");
|
||||
private static final String ENABLED_NOT_REQUIRED_AUTHENTICATION_COUNTER_NAME = name(BaseAccountAuthenticator.class,
|
||||
"enabledNotRequiredAuthentication");
|
||||
private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = "succeeded";
|
||||
private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason";
|
||||
private static final String ENABLED_TAG_NAME = "enabled";
|
||||
|
||||
private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(BaseAccountAuthenticator.class, "daysSinceLastSeen");
|
||||
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";
|
||||
|
||||
@VisibleForTesting
|
||||
static final char DEVICE_ID_SEPARATOR = '.';
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final Clock clock;
|
||||
|
||||
public BaseAccountAuthenticator(AccountsManager accountsManager) {
|
||||
this(accountsManager, Clock.systemUTC());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public BaseAccountAuthenticator(AccountsManager accountsManager, Clock clock) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
static Pair<String, Long> getIdentifierAndDeviceId(final String basicUsername) {
|
||||
final String identifier;
|
||||
final long deviceId;
|
||||
|
||||
final int deviceIdSeparatorIndex = basicUsername.indexOf(DEVICE_ID_SEPARATOR);
|
||||
|
||||
if (deviceIdSeparatorIndex == -1) {
|
||||
identifier = basicUsername;
|
||||
deviceId = Device.MASTER_ID;
|
||||
} else {
|
||||
identifier = basicUsername.substring(0, deviceIdSeparatorIndex);
|
||||
deviceId = Long.parseLong(basicUsername.substring(deviceIdSeparatorIndex + 1));
|
||||
}
|
||||
|
||||
return new Pair<>(identifier, deviceId);
|
||||
}
|
||||
|
||||
public Optional<AuthenticatedAccount> authenticate(BasicCredentials basicCredentials, boolean enabledRequired) {
|
||||
boolean succeeded = false;
|
||||
String failureReason = null;
|
||||
|
||||
try {
|
||||
final UUID accountUuid;
|
||||
final long deviceId;
|
||||
{
|
||||
final Pair<String, Long> identifierAndDeviceId = getIdentifierAndDeviceId(basicCredentials.getUsername());
|
||||
|
||||
accountUuid = UUID.fromString(identifierAndDeviceId.first());
|
||||
deviceId = identifierAndDeviceId.second();
|
||||
}
|
||||
|
||||
Optional<Account> account = accountsManager.getByAccountIdentifier(accountUuid);
|
||||
|
||||
if (account.isEmpty()) {
|
||||
failureReason = "noSuchAccount";
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<Device> device = account.get().getDevice(deviceId);
|
||||
|
||||
if (device.isEmpty()) {
|
||||
failureReason = "noSuchDevice";
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (enabledRequired) {
|
||||
final boolean deviceDisabled = !device.get().isEnabled();
|
||||
if (deviceDisabled) {
|
||||
failureReason = "deviceDisabled";
|
||||
}
|
||||
|
||||
final boolean accountDisabled = !account.get().isEnabled();
|
||||
if (accountDisabled) {
|
||||
failureReason = "accountDisabled";
|
||||
}
|
||||
if (accountDisabled || deviceDisabled) {
|
||||
return Optional.empty();
|
||||
}
|
||||
} else {
|
||||
Metrics.counter(ENABLED_NOT_REQUIRED_AUTHENTICATION_COUNTER_NAME,
|
||||
ENABLED_TAG_NAME, String.valueOf(device.get().isEnabled() && account.get().isEnabled()),
|
||||
IS_PRIMARY_DEVICE_TAG, String.valueOf(device.get().isMaster()))
|
||||
.increment();
|
||||
}
|
||||
|
||||
SaltedTokenHash deviceSaltedTokenHash = device.get().getAuthTokenHash();
|
||||
if (deviceSaltedTokenHash.verify(basicCredentials.getPassword())) {
|
||||
succeeded = true;
|
||||
Account authenticatedAccount = updateLastSeen(account.get(), device.get());
|
||||
if (deviceSaltedTokenHash.getVersion() != SaltedTokenHash.CURRENT_VERSION) {
|
||||
authenticatedAccount = accountsManager.updateDeviceAuthentication(
|
||||
authenticatedAccount,
|
||||
device.get(),
|
||||
SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version
|
||||
}
|
||||
return Optional.of(new AuthenticatedAccount(
|
||||
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
} catch (IllegalArgumentException | InvalidAuthorizationHeaderException iae) {
|
||||
failureReason = "invalidHeader";
|
||||
return Optional.empty();
|
||||
} finally {
|
||||
Tags tags = Tags.of(
|
||||
AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded));
|
||||
|
||||
if (StringUtils.isNotBlank(failureReason)) {
|
||||
tags = tags.and(AUTHENTICATION_FAILURE_REASON_TAG_NAME, failureReason);
|
||||
}
|
||||
|
||||
Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public Account updateLastSeen(Account account, Device device) {
|
||||
// compute a non-negative integer between 0 and 86400.
|
||||
long n = Util.ensureNonNegativeLong(account.getUuid().getLeastSignificantBits());
|
||||
final long lastSeenOffsetSeconds = n % ChronoUnit.DAYS.getDuration().toSeconds();
|
||||
|
||||
// produce a truncated timestamp which is either today at UTC midnight
|
||||
// or yesterday at UTC midnight, based on per-user randomized offset used.
|
||||
final long todayInMillisWithOffset = Util.todayInMillisGivenOffsetFromNow(clock, Duration.ofSeconds(lastSeenOffsetSeconds).negated());
|
||||
|
||||
// only update the device's last seen time when it falls behind the truncated timestamp.
|
||||
// this ensure a few things:
|
||||
// (1) each account will only update last-seen at most once per day
|
||||
// (2) these updates will occur throughout the day rather than all occurring at UTC midnight.
|
||||
if (device.getLastSeen() < todayInMillisWithOffset) {
|
||||
Metrics.summary(DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME, IS_PRIMARY_DEVICE_TAG, String.valueOf(device.isMaster()))
|
||||
.record(Duration.ofMillis(todayInMillisWithOffset - device.getLastSeen()).toDays());
|
||||
|
||||
return accountsManager.updateDeviceLastSeen(account, device, Util.todayInMillis(clock));
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import org.whispersystems.textsecuregcm.util.Pair;
|
||||
public class BasicAuthorizationHeader {
|
||||
|
||||
private final String username;
|
||||
private final long deviceId;
|
||||
private final byte deviceId;
|
||||
private final String password;
|
||||
|
||||
private BasicAuthorizationHeader(final String username, final long deviceId, final String password) {
|
||||
private BasicAuthorizationHeader(final String username, final byte deviceId, final String password) {
|
||||
this.username = username;
|
||||
this.deviceId = deviceId;
|
||||
this.password = password;
|
||||
@@ -59,10 +59,10 @@ public class BasicAuthorizationHeader {
|
||||
final String usernameComponent = credentials.substring(0, credentialSeparatorIndex);
|
||||
|
||||
final String username;
|
||||
final long deviceId;
|
||||
final byte deviceId;
|
||||
{
|
||||
final Pair<String, Long> identifierAndDeviceId =
|
||||
BaseAccountAuthenticator.getIdentifierAndDeviceId(usernameComponent);
|
||||
final Pair<String, Byte> identifierAndDeviceId =
|
||||
AccountAuthenticator.getIdentifierAndDeviceId(usernameComponent);
|
||||
|
||||
username = identifierAndDeviceId.first();
|
||||
deviceId = identifierAndDeviceId.second();
|
||||
|
||||
@@ -9,10 +9,10 @@ import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
@@ -30,13 +30,13 @@ public class CertificateGenerator {
|
||||
this.serverCertificate = ServerCertificate.parseFrom(serverCertificate);
|
||||
}
|
||||
|
||||
public byte[] createFor(Account account, Device device, boolean includeE164) throws InvalidKeyException {
|
||||
public byte[] createFor(final Account account, final byte deviceId, boolean includeE164) throws InvalidKeyException {
|
||||
SenderCertificate.Certificate.Builder builder = SenderCertificate.Certificate.newBuilder()
|
||||
.setSenderDevice(Math.toIntExact(device.getId()))
|
||||
.setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays))
|
||||
.setIdentityKey(ByteString.copyFrom(account.getIdentityKey().serialize()))
|
||||
.setSigner(serverCertificate)
|
||||
.setSenderUuid(account.getUuid().toString());
|
||||
.setSenderDevice(Math.toIntExact(deviceId))
|
||||
.setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays))
|
||||
.setIdentityKey(ByteString.copyFrom(account.getIdentityKey(IdentityType.ACI).serialize()))
|
||||
.setSigner(serverCertificate)
|
||||
.setSenderUuid(account.getUuid().toString());
|
||||
|
||||
if (includeE164) {
|
||||
builder.setSender(account.getNumber());
|
||||
@@ -44,11 +44,7 @@ public class CertificateGenerator {
|
||||
|
||||
byte[] certificate = builder.build().toByteArray();
|
||||
byte[] signature;
|
||||
try {
|
||||
signature = Curve.calculateSignature(privateKey, certificate);
|
||||
} catch (org.signal.libsignal.protocol.InvalidKeyException e) {
|
||||
throw new InvalidKeyException(e);
|
||||
}
|
||||
signature = privateKey.calculateSignature(certificate);
|
||||
|
||||
return SenderCertificate.newBuilder()
|
||||
.setCertificate(ByteString.copyFrom(certificate))
|
||||
|
||||
@@ -16,5 +16,5 @@ import java.lang.annotation.Target;
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ChangesDeviceEnabledState {
|
||||
public @interface ChangesLinkedDevices {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Indicates that an endpoint changes the phone number and PNI keys associated with an account, and that
|
||||
* any websockets associated with the account may need to be refreshed after a call to that endpoint.
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ChangesPhoneNumber {
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import io.netty.resolver.dns.DnsNameResolver;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.IOException;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public class CloudflareTurnCredentialsManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CloudflareTurnCredentialsManager.class);
|
||||
|
||||
private final List<String> cloudflareTurnUrls;
|
||||
private final List<String> cloudflareTurnUrlsWithIps;
|
||||
private final String cloudflareTurnHostname;
|
||||
private final HttpRequest getCredentialsRequest;
|
||||
|
||||
private final FaultTolerantHttpClient cloudflareTurnClient;
|
||||
private final DnsNameResolver dnsNameResolver;
|
||||
|
||||
private final Duration clientCredentialTtl;
|
||||
|
||||
private record CredentialRequest(long ttl) {}
|
||||
|
||||
private record CloudflareTurnResponse(IceServer iceServers) {
|
||||
|
||||
private record IceServer(
|
||||
String username,
|
||||
String credential,
|
||||
List<String> urls) {
|
||||
}
|
||||
}
|
||||
|
||||
public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,
|
||||
final String cloudflareTurnEndpoint,
|
||||
final Duration requestedCredentialTtl,
|
||||
final Duration clientCredentialTtl,
|
||||
final List<String> cloudflareTurnUrls,
|
||||
final List<String> cloudflareTurnUrlsWithIps,
|
||||
final String cloudflareTurnHostname,
|
||||
final int cloudflareTurnNumHttpClients,
|
||||
@Nullable final String circuitBreakerConfigurationName,
|
||||
final ExecutorService executor,
|
||||
@Nullable final String retryConfigurationName,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final DnsNameResolver dnsNameResolver) {
|
||||
|
||||
this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder("cloudflare-turn", executor)
|
||||
.withCircuitBreaker(circuitBreakerConfigurationName)
|
||||
.withRetry(retryConfigurationName, retryExecutor)
|
||||
.withNumClients(cloudflareTurnNumHttpClients)
|
||||
.build();
|
||||
this.cloudflareTurnUrls = cloudflareTurnUrls;
|
||||
this.cloudflareTurnUrlsWithIps = cloudflareTurnUrlsWithIps;
|
||||
this.cloudflareTurnHostname = cloudflareTurnHostname;
|
||||
this.dnsNameResolver = dnsNameResolver;
|
||||
|
||||
final String credentialsRequestBody;
|
||||
|
||||
try {
|
||||
credentialsRequestBody =
|
||||
SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(requestedCredentialTtl.toSeconds()));
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
// We repeat the same request to Cloudflare every time, so we can construct it once and re-use it
|
||||
this.getCredentialsRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(cloudflareTurnEndpoint))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(credentialsRequestBody))
|
||||
.build();
|
||||
|
||||
this.clientCredentialTtl = clientCredentialTtl;
|
||||
}
|
||||
|
||||
public TurnToken retrieveFromCloudflare() throws IOException {
|
||||
final List<String> cloudflareTurnComposedUrls;
|
||||
try {
|
||||
cloudflareTurnComposedUrls = dnsNameResolver.resolveAll(cloudflareTurnHostname).get().stream()
|
||||
.map(i -> switch (i) {
|
||||
case Inet6Address i6 -> "[" + i6.getHostAddress() + "]";
|
||||
default -> i.getHostAddress();
|
||||
})
|
||||
.flatMap(i -> cloudflareTurnUrlsWithIps.stream().map(u -> u.formatted(i)))
|
||||
.toList();
|
||||
} catch (Exception e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
final HttpResponse<String> response;
|
||||
try {
|
||||
response = cloudflareTurnClient.sendAsync(getCredentialsRequest, HttpResponse.BodyHandlers.ofString()).join();
|
||||
} catch (CompletionException e) {
|
||||
logger.warn("failed to make http request to Cloudflare Turn: {}", e.getMessage());
|
||||
throw new IOException(ExceptionUtils.unwrap(e));
|
||||
}
|
||||
|
||||
if (response.statusCode() != Response.Status.CREATED.getStatusCode()) {
|
||||
logger.warn("failure request credentials from Cloudflare Turn (code={}): {}", response.statusCode(), response);
|
||||
throw new IOException("Cloudflare Turn http failure : " + response.statusCode());
|
||||
}
|
||||
|
||||
final CloudflareTurnResponse cloudflareTurnResponse = SystemMapper.jsonMapper()
|
||||
.readValue(response.body(), CloudflareTurnResponse.class);
|
||||
|
||||
return new TurnToken(
|
||||
cloudflareTurnResponse.iceServers().username(),
|
||||
cloudflareTurnResponse.iceServers().credential(),
|
||||
clientCredentialTtl.toSeconds(),
|
||||
cloudflareTurnUrls == null ? Collections.emptyList() : cloudflareTurnUrls,
|
||||
cloudflareTurnComposedUrls,
|
||||
cloudflareTurnHostname
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import java.util.Base64;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
|
||||
public class CombinedUnidentifiedSenderAccessKeys {
|
||||
private final byte[] combinedUnidentifiedSenderAccessKeys;
|
||||
@@ -16,7 +16,7 @@ public class CombinedUnidentifiedSenderAccessKeys {
|
||||
public CombinedUnidentifiedSenderAccessKeys(String header) {
|
||||
try {
|
||||
this.combinedUnidentifiedSenderAccessKeys = Base64.getDecoder().decode(header);
|
||||
if (this.combinedUnidentifiedSenderAccessKeys == null || this.combinedUnidentifiedSenderAccessKeys.length != 16) {
|
||||
if (this.combinedUnidentifiedSenderAccessKeys == null || this.combinedUnidentifiedSenderAccessKeys.length != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {
|
||||
throw new WebApplicationException("Invalid combined unidentified sender access keys", Status.UNAUTHORIZED);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import javax.ws.rs.core.SecurityContext;
|
||||
import java.util.Optional;
|
||||
|
||||
class ContainerRequestUtil {
|
||||
|
||||
static Optional<Account> getAuthenticatedAccount(final ContainerRequest request) {
|
||||
return Optional.ofNullable(request.getSecurityContext())
|
||||
.map(SecurityContext::getUserPrincipal)
|
||||
.map(principal -> principal instanceof AccountAndAuthenticatedDeviceHolder
|
||||
? ((AccountAndAuthenticatedDeviceHolder) principal).getAccount() : null);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import io.dropwizard.auth.Authenticator;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
public class DisabledPermittedAccountAuthenticator extends BaseAccountAuthenticator implements
|
||||
Authenticator<BasicCredentials, DisabledPermittedAuthenticatedAccount> {
|
||||
|
||||
public DisabledPermittedAccountAuthenticator(AccountsManager accountsManager) {
|
||||
super(accountsManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DisabledPermittedAuthenticatedAccount> authenticate(BasicCredentials credentials) {
|
||||
Optional<AuthenticatedAccount> account = super.authenticate(credentials, false);
|
||||
return account.map(DisabledPermittedAuthenticatedAccount::new);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.security.Principal;
|
||||
import javax.security.auth.Subject;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class DisabledPermittedAuthenticatedAccount implements Principal, AccountAndAuthenticatedDeviceHolder {
|
||||
|
||||
private final AuthenticatedAccount authenticatedAccount;
|
||||
|
||||
public DisabledPermittedAuthenticatedAccount(final AuthenticatedAccount authenticatedAccount) {
|
||||
this.authenticatedAccount = authenticatedAccount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Account getAccount() {
|
||||
return authenticatedAccount.getAccount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Device getAuthenticatedDevice() {
|
||||
return authenticatedAccount.getAuthenticatedDevice();
|
||||
}
|
||||
|
||||
// Principal implementation
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean implies(Subject subject) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
/**
|
||||
* A disconnection request listener receives and handles a request to close an authenticated network connection for a
|
||||
* specific client.
|
||||
*/
|
||||
public interface DisconnectionRequestListener {
|
||||
|
||||
/**
|
||||
* Handles a request to close an authenticated network connection for a specific authenticated device. Requests are
|
||||
* dispatched on dedicated threads, and implementations may safely block.
|
||||
*/
|
||||
void handleDisconnectionRequest();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import io.lettuce.core.RedisCommandTimeoutException;
|
||||
import io.lettuce.core.pubsub.RedisPubSubAdapter;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.ResilienceUtil;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
|
||||
/**
|
||||
* A disconnection request manager broadcasts and dispatches requests for servers to close authenticated connections
|
||||
* from specific clients.
|
||||
*
|
||||
* @see DisconnectionRequestListener
|
||||
*/
|
||||
public class DisconnectionRequestManager extends RedisPubSubAdapter<byte[], byte[]> implements Managed {
|
||||
|
||||
private final FaultTolerantRedisClient pubSubClient;
|
||||
private final Executor listenerEventExecutor;
|
||||
private final ScheduledExecutorService retryExecutor;
|
||||
|
||||
private static final String RETRY_NAME = ResilienceUtil.name(DisconnectionRequestManager.class);
|
||||
|
||||
private static final Duration SUBSCRIBE_RETRY_DELAY = Duration.ofSeconds(5);
|
||||
|
||||
private final Map<AccountIdentifierAndDeviceId, List<DisconnectionRequestListener>> listeners =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
@Nullable
|
||||
private FaultTolerantPubSubConnection<byte[], byte[]> pubSubConnection;
|
||||
|
||||
private static final byte[] DISCONNECTION_REQUEST_CHANNEL = "disconnection_requests".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final Counter DISCONNECTION_REQUESTS_SENT_COUNTER =
|
||||
Metrics.counter(MetricsUtil.name(DisconnectionRequestManager.class, "requestsSent"));
|
||||
|
||||
private static final Counter DISCONNECTION_REQUESTS_RECEIVED_COUNTER =
|
||||
Metrics.counter(MetricsUtil.name(DisconnectionRequestManager.class, "requestsReceived"));
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DisconnectionRequestManager.class);
|
||||
|
||||
private record AccountIdentifierAndDeviceId(UUID accountIdentifier, byte deviceId) {}
|
||||
|
||||
public DisconnectionRequestManager(final FaultTolerantRedisClient pubSubClient,
|
||||
final Executor listenerEventExecutor,
|
||||
final ScheduledExecutorService retryExecutor) {
|
||||
|
||||
this.pubSubClient = pubSubClient;
|
||||
this.listenerEventExecutor = listenerEventExecutor;
|
||||
this.retryExecutor = retryExecutor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() {
|
||||
this.pubSubConnection = pubSubClient.createBinaryPubSubConnection();
|
||||
this.pubSubConnection.usePubSubConnection(connection -> {
|
||||
connection.addListener(this);
|
||||
|
||||
boolean subscribed = false;
|
||||
|
||||
// Loop indefinitely until we establish a subscription. We don't want to fail immediately if there's a temporary
|
||||
// Redis connectivity issue, since that would derail the whole startup process and likely lead to unnecessary pod
|
||||
// churn, which might make things worse. If we never establish a connection, readiness probes will eventually fail
|
||||
// and terminate the pods.
|
||||
do {
|
||||
try {
|
||||
connection.sync().subscribe(DISCONNECTION_REQUEST_CHANNEL);
|
||||
subscribed = true;
|
||||
} catch (final RedisCommandTimeoutException e) {
|
||||
try {
|
||||
Thread.sleep(SUBSCRIBE_RETRY_DELAY);
|
||||
} catch (final InterruptedException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
} while (!subscribed);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
if (pubSubConnection != null) {
|
||||
pubSubConnection.usePubSubConnection(connection -> {
|
||||
connection.removeListener(this);
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
pubSubConnection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener for disconnection requests for a specific authenticated device.
|
||||
*
|
||||
* @param accountIdentifier TODO
|
||||
* @param deviceId TODO
|
||||
* @param listener the listener to register
|
||||
*/
|
||||
public void addListener(final UUID accountIdentifier, final byte deviceId, final DisconnectionRequestListener listener) {
|
||||
listeners.compute(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), (_, existingListeners) -> {
|
||||
final List<DisconnectionRequestListener> listeners =
|
||||
existingListeners == null ? new ArrayList<>() : existingListeners;
|
||||
|
||||
listeners.add(listener);
|
||||
|
||||
return listeners;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a listener for disconnection requests for a specific authenticated device.
|
||||
*
|
||||
* @param accountIdentifier TODO
|
||||
* @param deviceId TODO
|
||||
* @param listener the listener to remove
|
||||
*/
|
||||
public void removeListener(final UUID accountIdentifier, final byte deviceId, final DisconnectionRequestListener listener) {
|
||||
listeners.computeIfPresent(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), (_, existingListeners) -> {
|
||||
existingListeners.remove(listener);
|
||||
|
||||
return existingListeners.isEmpty() ? null : existingListeners;
|
||||
});
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
List<DisconnectionRequestListener> getListeners(final UUID accountIdentifier, final byte deviceId) {
|
||||
return listeners.getOrDefault(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a request to close all connections associated with the given account identifier to all servers.
|
||||
*
|
||||
* @param account the account for which to close connections
|
||||
*
|
||||
* @return a future that completes when the request has been broadcast
|
||||
*/
|
||||
public CompletionStage<Void> requestDisconnection(final Account account) {
|
||||
return requestDisconnection(account.getIdentifier(IdentityType.ACI),
|
||||
account.getDevices().stream().map(Device::getId).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a request to close connections associated with the given account identifier and device IDs to all
|
||||
* servers.
|
||||
*
|
||||
* @param accountIdentifier the account for which to close connections
|
||||
* @param deviceIds the device IDs for which to close connections
|
||||
*
|
||||
* @return a future that completes when the request has been broadcast
|
||||
*/
|
||||
public CompletionStage<Void> requestDisconnection(final UUID accountIdentifier, final Collection<Byte> deviceIds) {
|
||||
final DisconnectionRequest disconnectionRequest = DisconnectionRequest.newBuilder()
|
||||
.setAccountIdentifier(UUIDUtil.toByteString(accountIdentifier))
|
||||
.addAllDeviceIds(deviceIds.stream().mapToInt(Byte::intValue).boxed().toList())
|
||||
.build();
|
||||
|
||||
return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)
|
||||
.executeCompletionStage(retryExecutor, () -> pubSubClient.withBinaryConnection(connection ->
|
||||
connection.async().publish(DISCONNECTION_REQUEST_CHANNEL, disconnectionRequest.toByteArray()))
|
||||
.toCompletableFuture())
|
||||
.thenRun(DISCONNECTION_REQUESTS_SENT_COUNTER::increment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void message(final byte[] channel, final byte[] message) {
|
||||
final UUID accountIdentifier;
|
||||
final List<Byte> deviceIds;
|
||||
|
||||
try {
|
||||
final DisconnectionRequest disconnectionRequest = DisconnectionRequest.parseFrom(message);
|
||||
DISCONNECTION_REQUESTS_RECEIVED_COUNTER.increment();
|
||||
|
||||
accountIdentifier = UUIDUtil.fromByteString(disconnectionRequest.getAccountIdentifier());
|
||||
deviceIds = disconnectionRequest.getDeviceIdsList().stream()
|
||||
.map(deviceIdInt -> {
|
||||
if (deviceIdInt == null || deviceIdInt < Device.PRIMARY_ID || deviceIdInt > Byte.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Invalid device ID: " + deviceIdInt);
|
||||
}
|
||||
|
||||
return deviceIdInt.byteValue();
|
||||
})
|
||||
.toList();
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Could not parse disconnection request protobuf", e);
|
||||
return;
|
||||
} catch (final IllegalArgumentException e) {
|
||||
logger.error("Could not parse part of disconnection request", e);
|
||||
return;
|
||||
}
|
||||
|
||||
deviceIds.forEach(deviceId -> {
|
||||
listeners.getOrDefault(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), Collections.emptyList())
|
||||
.forEach(listener -> listenerEventExecutor.execute(() -> {
|
||||
try {
|
||||
listener.handleDisconnectionRequest();
|
||||
} catch (final Exception e) {
|
||||
logger.warn("Listener failed to handle disconnection request", e);
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ public class ExternalServiceCredentialsGenerator {
|
||||
|
||||
/**
|
||||
* Given an instance of {@link ExternalServiceCredentials} object, checks that the password
|
||||
* matches the username taking into accound this generator's configuration.
|
||||
* matches the username taking into account this generator's configuration.
|
||||
* @param credentials an instance of {@link ExternalServiceCredentials}
|
||||
* @return An optional with a timestamp (seconds) of when the credentials were generated,
|
||||
* or an empty optional if the password doesn't match the username for any reason (including malformed data)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import java.util.Base64;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
|
||||
|
||||
public record GroupSendTokenHeader(GroupSendFullToken token) {
|
||||
|
||||
public static GroupSendTokenHeader valueOf(String header) {
|
||||
try {
|
||||
return new GroupSendTokenHeader(new GroupSendFullToken(Base64.getDecoder().decode(header)));
|
||||
} catch (InvalidInputException | IllegalArgumentException e) {
|
||||
// Base64 throws IllegalArgumentException; GroupSendFullToken ctor throws InvalidInputException
|
||||
throw new WebApplicationException(e, Status.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;
|
||||
|
||||
public class IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter implements
|
||||
AuthenticatedWebSocketUpgradeFilter<AuthenticatedDevice> {
|
||||
|
||||
private final Duration minIdleDuration;
|
||||
private final Clock clock;
|
||||
|
||||
@VisibleForTesting
|
||||
static final String ALERT_HEADER = "X-Signal-Alert";
|
||||
|
||||
@VisibleForTesting
|
||||
static final String IDLE_PRIMARY_DEVICE_ALERT = "idle-primary-device";
|
||||
|
||||
private static final Counter IDLE_PRIMARY_WARNING_COUNTER = Metrics.counter(
|
||||
MetricsUtil.name(IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.class, "idlePrimaryDeviceWarning"),
|
||||
"critical", "false");
|
||||
|
||||
public IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(final Duration minIdleDuration, final Clock clock) {
|
||||
this.minIdleDuration = minIdleDuration;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAuthentication(final Optional<AuthenticatedDevice> authenticated,
|
||||
final JettyServerUpgradeRequest request,
|
||||
final JettyServerUpgradeResponse response) {
|
||||
|
||||
// No action needed if the connection is unauthenticated (in which case we don't know when we've last seen the
|
||||
// primary device) or if the authenticated device IS the primary device
|
||||
authenticated
|
||||
.filter(authenticatedDevice -> authenticatedDevice.deviceId() != Device.PRIMARY_ID)
|
||||
.ifPresent(authenticatedDevice -> {
|
||||
if (authenticatedDevice.primaryDeviceLastSeen().isBefore(clock.instant().minus(minIdleDuration))) {
|
||||
response.addHeader(ALERT_HEADER, IDLE_PRIMARY_DEVICE_ALERT);
|
||||
IDLE_PRIMARY_WARNING_COUNTER.increment();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
|
||||
public class InvalidAuthorizationHeaderException extends WebApplicationException {
|
||||
public InvalidAuthorizationHeaderException(String s) {
|
||||
|
||||
@@ -5,35 +5,37 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
import javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class OptionalAccess {
|
||||
|
||||
public static final String UNIDENTIFIED = "Unidentified-Access-Key";
|
||||
public static String ALL_DEVICES_SELECTOR = "*";
|
||||
|
||||
public static void verify(Optional<Account> requestAccount,
|
||||
Optional<Anonymous> accessKey,
|
||||
Optional<Account> targetAccount,
|
||||
ServiceIdentifier targetIdentifier,
|
||||
String deviceSelector) {
|
||||
|
||||
public static void verify(Optional<Account> requestAccount,
|
||||
Optional<Anonymous> accessKey,
|
||||
Optional<Account> targetAccount,
|
||||
String deviceSelector)
|
||||
{
|
||||
try {
|
||||
verify(requestAccount, accessKey, targetAccount);
|
||||
verify(requestAccount, accessKey, targetAccount, targetIdentifier);
|
||||
|
||||
if (!deviceSelector.equals("*")) {
|
||||
long deviceId = Long.parseLong(deviceSelector);
|
||||
if (!ALL_DEVICES_SELECTOR.equals(deviceSelector)) {
|
||||
byte deviceId = Byte.parseByte(deviceSelector);
|
||||
|
||||
Optional<Device> targetDevice = targetAccount.get().getDevice(deviceId);
|
||||
|
||||
if (targetDevice.isPresent() && targetDevice.get().isEnabled()) {
|
||||
if (targetDevice.isPresent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,29 +50,50 @@ public class OptionalAccess {
|
||||
}
|
||||
}
|
||||
|
||||
public static void verify(Optional<Account> requestAccount,
|
||||
Optional<Anonymous> accessKey,
|
||||
Optional<Account> targetAccount)
|
||||
{
|
||||
if (requestAccount.isPresent() && targetAccount.isPresent() && targetAccount.get().isEnabled()) {
|
||||
public static void verify(Optional<Account> requestAccount,
|
||||
Optional<Anonymous> accessKey,
|
||||
Optional<Account> targetAccount,
|
||||
ServiceIdentifier targetIdentifier) {
|
||||
|
||||
if (requestAccount.isPresent()) {
|
||||
// Authenticated requests are never unauthorized; if the target exists, return OK, otherwise throw not-found.
|
||||
if (targetAccount.isPresent()) {
|
||||
return;
|
||||
} else {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// Anything past this point can only be authenticated by an access key. Even when the target
|
||||
// has unrestricted unidentified access, callers need to supply a fake access key. Likewise, if
|
||||
// the target account does not exist, we *also* report unauthorized here (*not* not-found,
|
||||
// since that would provide a free exists check).
|
||||
if (accessKey.isEmpty() || targetAccount.isEmpty()) {
|
||||
throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Unidentified access is only for ACI identities
|
||||
if (IdentityType.PNI.equals(targetIdentifier.identityType())) {
|
||||
throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Unrestricted unidentified access does what it says on the tin: we don't check if the key the
|
||||
// caller provided is right or not.
|
||||
if (targetAccount.get().isUnrestrictedUnidentifiedAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//noinspection ConstantConditions
|
||||
if (requestAccount.isPresent() && (targetAccount.isEmpty() || (targetAccount.isPresent() && !targetAccount.get().isEnabled()))) {
|
||||
throw new NotFoundException();
|
||||
if (!targetAccount.get().isIdentifiedBy(targetIdentifier)) {
|
||||
throw new IllegalArgumentException("Target account is not identified by the given identifier");
|
||||
}
|
||||
|
||||
if (accessKey.isPresent() && targetAccount.isPresent() && targetAccount.get().isEnabled() && targetAccount.get().isUnrestrictedUnidentifiedAccess()) {
|
||||
return;
|
||||
// At this point, any successful authentication requires a real access key on the target account
|
||||
if (targetAccount.get().getUnidentifiedAccessKey().isEmpty()) {
|
||||
throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (accessKey.isPresent() &&
|
||||
targetAccount.isPresent() &&
|
||||
targetAccount.get().getUnidentifiedAccessKey().isPresent() &&
|
||||
targetAccount.get().isEnabled() &&
|
||||
MessageDigest.isEqual(accessKey.get().getAccessKey(), targetAccount.get().getUnidentifiedAccessKey().get()))
|
||||
{
|
||||
// Otherwise, access is gated by the caller having the unidentified-access key matching the target account.
|
||||
if (MessageDigest.isEqual(accessKey.get().getAccessKey(), targetAccount.get().getUnidentifiedAccessKey().get())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
public class PhoneNumberChangeRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
|
||||
|
||||
private static final String INITIAL_NUMBER_KEY =
|
||||
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".initialNumber";
|
||||
|
||||
@Override
|
||||
public void handleRequestFiltered(final RequestEvent requestEvent) {
|
||||
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest())
|
||||
.ifPresent(account -> requestEvent.getContainerRequest().setProperty(INITIAL_NUMBER_KEY, account.getNumber()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<UUID, Long>> handleRequestFinished(final RequestEvent requestEvent) {
|
||||
final String initialNumber = (String) requestEvent.getContainerRequest().getProperty(INITIAL_NUMBER_KEY);
|
||||
|
||||
if (initialNumber != null) {
|
||||
final Optional<Account> maybeAuthenticatedAccount =
|
||||
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest());
|
||||
|
||||
return maybeAuthenticatedAccount
|
||||
.filter(account -> !initialNumber.equals(account.getNumber()))
|
||||
.map(account -> account.getDevices().stream()
|
||||
.map(device -> new Pair<>(account.getUuid(), device.getId()))
|
||||
.collect(Collectors.toList()))
|
||||
.orElse(Collections.emptyList());
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,22 +7,25 @@ package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.ForbiddenException;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.ServerErrorException;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.ServerErrorException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
|
||||
public class PhoneVerificationTokenManager {
|
||||
@@ -31,19 +34,27 @@ public class PhoneVerificationTokenManager {
|
||||
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final long VERIFICATION_TIMEOUT_SECONDS = REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds();
|
||||
|
||||
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
|
||||
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
private final RegistrationRecoveryChecker registrationRecoveryChecker;
|
||||
|
||||
public PhoneVerificationTokenManager(final RegistrationServiceClient registrationServiceClient,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) {
|
||||
public PhoneVerificationTokenManager(final PhoneNumberIdentifiers phoneNumberIdentifiers,
|
||||
final RegistrationServiceClient registrationServiceClient,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
final RegistrationRecoveryChecker registrationRecoveryChecker) {
|
||||
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.registrationRecoveryChecker = registrationRecoveryChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a {@link PhoneVerificationRequest} has a token that verifies the caller has confirmed access to the e164
|
||||
* number
|
||||
*
|
||||
* @param requestContext the container request context
|
||||
* @param number the e164 presented for verification
|
||||
* @param request the request with exactly one verification token (RegistrationService sessionId or registration
|
||||
* recovery password)
|
||||
@@ -54,13 +65,13 @@ public class PhoneVerificationTokenManager {
|
||||
* @throws ForbiddenException if the recovery password is not valid
|
||||
* @throws InterruptedException if verification did not complete before a timeout
|
||||
*/
|
||||
public PhoneVerificationRequest.VerificationType verify(final String number, final PhoneVerificationRequest request)
|
||||
public PhoneVerificationRequest.VerificationType verify(final ContainerRequestContext requestContext, final String number, final PhoneVerificationRequest request)
|
||||
throws InterruptedException {
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = request.verificationType();
|
||||
switch (verificationType) {
|
||||
case SESSION -> verifyBySessionId(number, request.decodeSessionId());
|
||||
case RECOVERY_PASSWORD -> verifyByRecoveryPassword(number, request.recoveryPassword());
|
||||
case RECOVERY_PASSWORD -> verifyByRecoveryPassword(requestContext, number, request.recoveryPassword());
|
||||
}
|
||||
|
||||
return verificationType;
|
||||
@@ -97,10 +108,13 @@ public class PhoneVerificationTokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyByRecoveryPassword(final String number, final byte[] recoveryPassword)
|
||||
private void verifyByRecoveryPassword(final ContainerRequestContext requestContext, final String number, final byte[] recoveryPassword)
|
||||
throws InterruptedException {
|
||||
if (!registrationRecoveryChecker.checkRegistrationRecoveryAttempt(requestContext, number)) {
|
||||
throw new ForbiddenException("recoveryPassword couldn't be verified");
|
||||
}
|
||||
try {
|
||||
final boolean verified = registrationRecoveryPasswordsManager.verify(number, recoveryPassword)
|
||||
final boolean verified = registrationRecoveryPasswordsManager.verify(phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join(), recoveryPassword)
|
||||
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!verified) {
|
||||
throw new ForbiddenException("recoveryPassword couldn't be verified");
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Iterator;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/// A validated range of days for which a credential may be issued.
|
||||
public class RedemptionRange implements Iterable<Instant> {
|
||||
|
||||
public static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
|
||||
|
||||
/// The first day for which a credential should be issued
|
||||
private final LocalDate from;
|
||||
|
||||
/// The last day for which a credential should be issued
|
||||
private final LocalDate end;
|
||||
|
||||
private RedemptionRange(final LocalDate from, final LocalDate end) {
|
||||
this.from = from;
|
||||
this.end = end;
|
||||
}
|
||||
|
||||
/// Construct a {@link RedemptionRange} if the provided day bounds are valid.
|
||||
///
|
||||
/// The redemption bounds must satisfy:
|
||||
/// - `redemptionEnd` >= `redemptionStart`
|
||||
/// - `redemptionStart` and `redemptionEnd` are day-aligned
|
||||
/// - `redemptionStart` is yesterday or later
|
||||
/// - `redemptionEnd` is tomorrow + `MAX_REDEMPTION_DURATION` or earlier
|
||||
/// - The number of days requested is less than `MAX_REDEMPTION_DURATION`
|
||||
///
|
||||
/// @param clock Clock to use to get current day
|
||||
/// @param redemptionStart The first day included in the range
|
||||
/// @param redemptionEnd The last day included in the range
|
||||
/// @return A {@link RedemptionRange} that can be used to iterate each day between `redemptionStart` and
|
||||
/// `redemptionEnd`
|
||||
/// @throws IllegalArgumentException if the redemption bounds were not valid
|
||||
public static RedemptionRange inclusive(Clock clock, Instant redemptionStart, Instant redemptionEnd)
|
||||
throws IllegalArgumentException {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
final Instant yesterday = today.minus(Duration.ofDays(1));
|
||||
|
||||
if (redemptionStart.isAfter(redemptionEnd)) {
|
||||
throw new IllegalArgumentException("end of range must be after start of range");
|
||||
}
|
||||
|
||||
if (!redemptionStart.truncatedTo(ChronoUnit.DAYS).equals(redemptionStart)
|
||||
|| !redemptionEnd.truncatedTo(ChronoUnit.DAYS).equals(redemptionEnd)) {
|
||||
throw new IllegalArgumentException("timestamps must be day aligned");
|
||||
}
|
||||
|
||||
if (redemptionStart.isBefore(yesterday)) {
|
||||
throw new IllegalArgumentException("start of range too far in the past");
|
||||
}
|
||||
|
||||
if (redemptionEnd.isAfter(today.plus(MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1)))) {
|
||||
throw new IllegalArgumentException("end of range too far in the future");
|
||||
}
|
||||
|
||||
if (redemptionEnd.isAfter(redemptionStart.plus(MAX_REDEMPTION_DURATION))) {
|
||||
throw new IllegalArgumentException("redemption window too large");
|
||||
}
|
||||
|
||||
return new RedemptionRange(
|
||||
LocalDate.ofInstant(redemptionStart, ZoneOffset.UTC),
|
||||
LocalDate.ofInstant(redemptionEnd, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Iterator<Instant> iterator() {
|
||||
final Instant fromInstant = from.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
final Instant endInstant = end.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
return Stream
|
||||
.iterate(fromInstant, redemptionTime -> redemptionTime.plus(Duration.ofDays(1)))
|
||||
.takeWhile(redemptionTime -> !redemptionTime.isAfter(endInstant))
|
||||
.iterator();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
RedemptionRange that = (RedemptionRange) o;
|
||||
return Objects.equals(from, that.from) && Objects.equals(end, that.end);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(from, end);
|
||||
}
|
||||
}
|
||||
@@ -12,25 +12,25 @@ import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class RegistrationLockVerificationManager {
|
||||
public enum Flow {
|
||||
@@ -53,23 +53,21 @@ public class RegistrationLockVerificationManager {
|
||||
private static final String PHONE_VERIFICATION_TYPE_TAG_NAME = "phoneVerificationType";
|
||||
|
||||
private final AccountsManager accounts;
|
||||
private final ClientPresenceManager clientPresenceManager;
|
||||
private final ExternalServiceCredentialsGenerator svr1CredentialGenerator;
|
||||
private final DisconnectionRequestManager disconnectionRequestManager;
|
||||
private final ExternalServiceCredentialsGenerator svr2CredentialGenerator;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
|
||||
public RegistrationLockVerificationManager(
|
||||
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
|
||||
final ExternalServiceCredentialsGenerator svr1CredentialGenerator,
|
||||
final AccountsManager accounts,
|
||||
final DisconnectionRequestManager disconnectionRequestManager,
|
||||
final ExternalServiceCredentialsGenerator svr2CredentialGenerator,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
final PushNotificationManager pushNotificationManager,
|
||||
final RateLimiters rateLimiters) {
|
||||
this.accounts = accounts;
|
||||
this.clientPresenceManager = clientPresenceManager;
|
||||
this.svr1CredentialGenerator = svr1CredentialGenerator;
|
||||
this.disconnectionRequestManager = disconnectionRequestManager;
|
||||
this.svr2CredentialGenerator = svr2CredentialGenerator;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
@@ -109,7 +107,7 @@ public class RegistrationLockVerificationManager {
|
||||
throw new RuntimeException("Unexpected status: " + existingRegistrationLock.getStatus());
|
||||
}
|
||||
|
||||
if (!Util.isEmpty(clientRegistrationLock)) {
|
||||
if (StringUtils.isNotEmpty(clientRegistrationLock)) {
|
||||
rateLimiters.getPinLimiter().validate(account.getNumber());
|
||||
}
|
||||
|
||||
@@ -141,9 +139,6 @@ public class RegistrationLockVerificationManager {
|
||||
// Freezing the existing account credentials will definitively start the reglock timeout.
|
||||
// Until the timeout, the current reglock can still be supplied,
|
||||
// along with phone number verification, to restore access.
|
||||
final ExternalServiceCredentials existingSvr1Credentials = svr1CredentialGenerator.generateForUuid(account.getUuid());
|
||||
final ExternalServiceCredentials existingSvr2Credentials = svr2CredentialGenerator.generateForUuid(account.getUuid());
|
||||
|
||||
final Account updatedAccount;
|
||||
if (!alreadyLocked) {
|
||||
updatedAccount = accounts.update(account, Account::lockAuthTokenHash);
|
||||
@@ -158,11 +153,11 @@ public class RegistrationLockVerificationManager {
|
||||
// This allows users to re-register via registration recovery password
|
||||
// instead of always being forced to fall back to SMS verification.
|
||||
if (!phoneVerificationType.equals(PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD) || clientRegistrationLock != null) {
|
||||
registrationRecoveryPasswordsManager.removeForNumber(updatedAccount.getNumber());
|
||||
registrationRecoveryPasswordsManager.remove(updatedAccount.getIdentifier(IdentityType.PNI));
|
||||
}
|
||||
|
||||
final List<Long> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();
|
||||
clientPresenceManager.disconnectAllPresences(updatedAccount.getUuid(), deviceIds);
|
||||
final List<Byte> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();
|
||||
disconnectionRequestManager.requestDisconnection(updatedAccount.getUuid(), deviceIds);
|
||||
|
||||
try {
|
||||
// Send a push notification that prompts the client to attempt login and fail due to locked credentials
|
||||
@@ -172,12 +167,20 @@ public class RegistrationLockVerificationManager {
|
||||
}
|
||||
|
||||
throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
|
||||
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining().toMillis(),
|
||||
existingRegistrationLock.needsFailureCredentials() ? existingSvr1Credentials : null,
|
||||
existingRegistrationLock.needsFailureCredentials() ? existingSvr2Credentials : null))
|
||||
.entity(new RegistrationLockFailure(
|
||||
existingRegistrationLock.getTimeRemaining().toMillis(),
|
||||
svr2FailureCredentials(existingRegistrationLock, updatedAccount)))
|
||||
.build());
|
||||
}
|
||||
|
||||
rateLimiters.getPinLimiter().clear(phoneNumber);
|
||||
}
|
||||
|
||||
private @Nullable ExternalServiceCredentials svr2FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {
|
||||
if (!existingRegistrationLock.needsFailureCredentials()) {
|
||||
return null;
|
||||
}
|
||||
return svr2CredentialGenerator.generateForUuid(account.getUuid());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class StoredRegistrationLock {
|
||||
@@ -73,16 +73,11 @@ public class StoredRegistrationLock {
|
||||
}
|
||||
|
||||
public boolean verify(@Nullable String clientRegistrationLock) {
|
||||
if (hasLockAndSalt() && Util.nonEmpty(clientRegistrationLock)) {
|
||||
if (hasLockAndSalt() && StringUtils.isNotEmpty(clientRegistrationLock)) {
|
||||
SaltedTokenHash credentials = new SaltedTokenHash(registrationLock.get(), registrationLockSalt.get());
|
||||
return credentials.verify(clientRegistrationLock);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public StoredRegistrationLock forTime(long timestamp) {
|
||||
return new StoredRegistrationLock(registrationLock, registrationLockSalt, Instant.ofEpochMilli(timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record TurnToken(String username, String password, List<String> urls) {
|
||||
public record TurnToken(
|
||||
String username,
|
||||
String password,
|
||||
@JsonProperty("ttl") long ttlSeconds,
|
||||
@Nonnull List<String> urls,
|
||||
@Nonnull List<String> urlsWithIps,
|
||||
@Nullable String hostname) {
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.util.WeightedRandomSelect;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TurnTokenGenerator {
|
||||
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
private final byte[] turnSecret;
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA1";
|
||||
|
||||
public TurnTokenGenerator(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final byte[] turnSecret) {
|
||||
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.turnSecret = turnSecret;
|
||||
}
|
||||
|
||||
public TurnToken generate(final UUID aci) {
|
||||
try {
|
||||
final List<String> urls = urls(aci);
|
||||
final Mac mac = Mac.getInstance(ALGORITHM);
|
||||
final long validUntilSeconds = Instant.now().plus(Duration.ofDays(1)).getEpochSecond();
|
||||
final long user = Util.ensureNonNegativeInt(new SecureRandom().nextInt());
|
||||
final String userTime = validUntilSeconds + ":" + user;
|
||||
|
||||
mac.init(new SecretKeySpec(turnSecret, ALGORITHM));
|
||||
final String password = Base64.getEncoder().encodeToString(mac.doFinal(userTime.getBytes()));
|
||||
|
||||
return new TurnToken(userTime, password, urls);
|
||||
} catch (final NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> urls(final UUID aci) {
|
||||
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
|
||||
|
||||
// Check if number is enrolled to test out specific turn servers
|
||||
final Optional<TurnUriConfiguration> enrolled = turnConfig.getUriConfigs().stream()
|
||||
.filter(config -> config.getEnrolledAcis().contains(aci))
|
||||
.findFirst();
|
||||
|
||||
if (enrolled.isPresent()) {
|
||||
return enrolled.get().getUris();
|
||||
}
|
||||
|
||||
// Otherwise, select from turn server sets by weighted choice
|
||||
return WeightedRandomSelect.select(turnConfig
|
||||
.getUriConfigs()
|
||||
.stream()
|
||||
.map(c -> new Pair<>(c.getUris(), c.getWeight())).toList());
|
||||
}
|
||||
}
|
||||
@@ -9,23 +9,21 @@ import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class UnidentifiedAccessChecksum {
|
||||
|
||||
public static String generateFor(Optional<byte[]> unidentifiedAccessKey) {
|
||||
public static byte[] generateFor(byte[] unidentifiedAccessKey) {
|
||||
try {
|
||||
if (!unidentifiedAccessKey.isPresent()|| unidentifiedAccessKey.get().length != 16) return null;
|
||||
if (unidentifiedAccessKey.length != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {
|
||||
throw new IllegalArgumentException("Invalid UAK length: " + unidentifiedAccessKey.length);
|
||||
}
|
||||
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(unidentifiedAccessKey.get(), "HmacSHA256"));
|
||||
mac.init(new SecretKeySpec(unidentifiedAccessKey, "HmacSHA256"));
|
||||
|
||||
return Base64.getEncoder().encodeToString(mac.doFinal(new byte[32]));
|
||||
return mac.doFinal(new byte[32]);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collection;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class UnidentifiedAccessUtil {
|
||||
|
||||
public static final int UNIDENTIFIED_ACCESS_KEY_LENGTH = 16;
|
||||
|
||||
private UnidentifiedAccessUtil() {
|
||||
}
|
||||
|
||||
@@ -29,4 +34,42 @@ public class UnidentifiedAccessUtil {
|
||||
.map(targetUnidentifiedAccessKey -> MessageDigest.isEqual(targetUnidentifiedAccessKey, unidentifiedAccessKey))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an action (e.g. sending a message or retrieving pre-keys) may be taken on the collection of target
|
||||
* accounts by an actor presenting the given combined unidentified access key.
|
||||
*
|
||||
* @param targetAccounts the accounts on which an actor wishes to take an action
|
||||
* @param combinedUnidentifiedAccessKey the unidentified access key presented by the actor
|
||||
*
|
||||
* @return {@code true} if an actor presenting the given unidentified access key has permission to take an action on
|
||||
* the target accounts or {@code false} otherwise
|
||||
*/
|
||||
public static boolean checkUnidentifiedAccess(final Collection<Account> targetAccounts, final byte[] combinedUnidentifiedAccessKey) {
|
||||
return MessageDigest.isEqual(getCombinedUnidentifiedAccessKey(targetAccounts), combinedUnidentifiedAccessKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a combined unidentified access key for the given collection of accounts.
|
||||
*
|
||||
* @param accounts the accounts from which to derive a combined unidentified access key
|
||||
* @return a combined unidentified access key
|
||||
*
|
||||
* @throws IllegalArgumentException if one or more of the given accounts had an unidentified access key with an
|
||||
* unexpected length
|
||||
*/
|
||||
public static byte[] getCombinedUnidentifiedAccessKey(final Collection<Account> accounts) {
|
||||
return accounts.stream()
|
||||
.filter(Predicate.not(Account::isUnrestrictedUnidentifiedAccess))
|
||||
.map(account ->
|
||||
account.getUnidentifiedAccessKey()
|
||||
.filter(b -> b.length == UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH)
|
||||
.orElseThrow(IllegalArgumentException::new))
|
||||
.reduce(new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH],
|
||||
(a, b) -> {
|
||||
final byte[] xor = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH];
|
||||
IntStream.range(0, UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH).forEach(i -> xor[i] = (byte) (a[i] ^ b[i]));
|
||||
return xor;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.glassfish.jersey.server.monitoring.ApplicationEvent;
|
||||
import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEventListener;
|
||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
/**
|
||||
* Delegates request events to a listener that watches for intra-request changes that require websocket refreshes
|
||||
*/
|
||||
public class WebsocketRefreshApplicationEventListener implements ApplicationEventListener {
|
||||
|
||||
private final WebsocketRefreshRequestEventListener websocketRefreshRequestEventListener;
|
||||
|
||||
public WebsocketRefreshApplicationEventListener(final AccountsManager accountsManager,
|
||||
final ClientPresenceManager clientPresenceManager) {
|
||||
|
||||
this.websocketRefreshRequestEventListener = new WebsocketRefreshRequestEventListener(clientPresenceManager,
|
||||
new AuthEnablementRefreshRequirementProvider(accountsManager),
|
||||
new PhoneNumberChangeRefreshRequirementProvider());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(final ApplicationEvent event) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestEventListener onRequest(final RequestEvent requestEvent) {
|
||||
return websocketRefreshRequestEventListener;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.ws.rs.container.ResourceInfo;
|
||||
import javax.ws.rs.core.Context;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent.Type;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEventListener;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||
|
||||
public class WebsocketRefreshRequestEventListener implements RequestEventListener {
|
||||
|
||||
private final ClientPresenceManager clientPresenceManager;
|
||||
private final WebsocketRefreshRequirementProvider[] providers;
|
||||
|
||||
private static final Counter DISPLACED_ACCOUNTS = Metrics.counter(
|
||||
name(WebsocketRefreshRequestEventListener.class, "displacedAccounts"));
|
||||
|
||||
private static final Counter DISPLACED_DEVICES = Metrics.counter(
|
||||
name(WebsocketRefreshRequestEventListener.class, "displacedDevices"));
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebsocketRefreshRequestEventListener.class);
|
||||
|
||||
public WebsocketRefreshRequestEventListener(
|
||||
final ClientPresenceManager clientPresenceManager,
|
||||
final WebsocketRefreshRequirementProvider... providers) {
|
||||
|
||||
this.clientPresenceManager = clientPresenceManager;
|
||||
this.providers = providers;
|
||||
}
|
||||
|
||||
@Context
|
||||
private ResourceInfo resourceInfo;
|
||||
|
||||
@Override
|
||||
public void onEvent(final RequestEvent event) {
|
||||
if (event.getType() == Type.REQUEST_FILTERED) {
|
||||
for (final WebsocketRefreshRequirementProvider provider : providers) {
|
||||
provider.handleRequestFiltered(event);
|
||||
}
|
||||
} else if (event.getType() == Type.FINISHED) {
|
||||
final AtomicInteger displacedDevices = new AtomicInteger(0);
|
||||
|
||||
Arrays.stream(providers)
|
||||
.flatMap(provider -> provider.handleRequestFinished(event).stream())
|
||||
.distinct()
|
||||
.forEach(pair -> {
|
||||
try {
|
||||
displacedDevices.incrementAndGet();
|
||||
clientPresenceManager.disconnectPresence(pair.first(), pair.second());
|
||||
} catch (final Exception e) {
|
||||
logger.error("Could not displace device presence", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (displacedDevices.get() > 0) {
|
||||
DISPLACED_ACCOUNTS.increment();
|
||||
DISPLACED_DEVICES.increment(displacedDevices.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
/**
|
||||
* A websocket refresh requirement provider watches for intra-request changes (e.g. to authentication status) that
|
||||
* require a websocket refresh.
|
||||
*/
|
||||
public interface WebsocketRefreshRequirementProvider {
|
||||
|
||||
/**
|
||||
* Processes a request after filters have run and the request has been mapped to a destination controller.
|
||||
*
|
||||
* @param requestEvent the request event to observe
|
||||
*/
|
||||
void handleRequestFiltered(RequestEvent requestEvent);
|
||||
|
||||
/**
|
||||
* Processes a request after all normal request handling has been completed.
|
||||
*
|
||||
* @param requestEvent the request event to observe
|
||||
* @return a list of pairs of account UUID/device ID pairs identifying websockets that need to be refreshed as a
|
||||
* result of the observed request
|
||||
*/
|
||||
List<Pair<UUID, Long>> handleRequestFinished(RequestEvent requestEvent);
|
||||
}
|
||||
@@ -7,5 +7,5 @@ package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record AuthenticatedDevice(UUID accountIdentifier, long deviceId) {
|
||||
public record AuthenticatedDevice(UUID accountIdentifier, byte deviceId) {
|
||||
}
|
||||
|
||||
@@ -7,37 +7,53 @@ package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import io.grpc.Context;
|
||||
import io.grpc.Status;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nullable;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.function.Tuple2;
|
||||
import reactor.util.function.Tuples;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
/**
|
||||
* Provides utility methods for working with authentication in the context of gRPC calls.
|
||||
*/
|
||||
public class AuthenticationUtil {
|
||||
|
||||
static final Context.Key<UUID> CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY = Context.key("authenticated-aci");
|
||||
static final Context.Key<Long> CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY = Context.key("authenticated-device-id");
|
||||
static final Context.Key<AuthenticatedDevice> CONTEXT_AUTHENTICATED_DEVICE = Context.key("authenticated-device");
|
||||
|
||||
/**
|
||||
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
|
||||
* no authenticated account/device is available.
|
||||
*
|
||||
* @return the account/device authenticated in the current gRPC context
|
||||
* @return the account/device identifier authenticated in the current gRPC context
|
||||
*
|
||||
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
|
||||
* could be retrieved from the current gRPC context
|
||||
*/
|
||||
public static AuthenticatedDevice requireAuthenticatedDevice() {
|
||||
@Nullable final UUID accountIdentifier = CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY.get();
|
||||
@Nullable final Long deviceId = CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY.get();
|
||||
@Nullable final AuthenticatedDevice authenticatedDevice = CONTEXT_AUTHENTICATED_DEVICE.get();
|
||||
|
||||
if (accountIdentifier != null && deviceId != null) {
|
||||
return new AuthenticatedDevice(accountIdentifier, deviceId);
|
||||
if (authenticatedDevice != null) {
|
||||
return authenticatedDevice;
|
||||
}
|
||||
|
||||
throw Status.UNAUTHENTICATED.asRuntimeException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
|
||||
* no authenticated account/device is available or "permission denied" if the authenticated device is not the primary
|
||||
* device for the account.
|
||||
*
|
||||
* @return the account/device identifier authenticated in the current gRPC context
|
||||
*
|
||||
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
|
||||
* could be retrieved from the current gRPC context or a status of {@code PERMISSION_DENIED} if the authenticated
|
||||
* device is not the primary device for the authenticated account
|
||||
*/
|
||||
public static AuthenticatedDevice requireAuthenticatedPrimaryDevice() {
|
||||
final AuthenticatedDevice authenticatedDevice = requireAuthenticatedDevice();
|
||||
|
||||
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
|
||||
throw Status.PERMISSION_DENIED.asRuntimeException();
|
||||
}
|
||||
|
||||
return authenticatedDevice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import io.grpc.Context;
|
||||
import io.grpc.Contexts;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import java.util.Optional;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator;
|
||||
|
||||
/**
|
||||
* A basic credential authentication interceptor enforces the presence of a valid username and password on every call.
|
||||
* Callers supply credentials by providing a username (UUID and optional device ID) and password pair in the
|
||||
* {@code x-signal-basic-auth-credentials} call header.
|
||||
* <p/>
|
||||
* Downstream services can retrieve the identity of the authenticated caller using methods in
|
||||
* {@link AuthenticationUtil}.
|
||||
* <p/>
|
||||
* Note that this authentication, while fully functional, is intended only for development and testing purposes and is
|
||||
* intended to be replaced with a more robust and efficient strategy before widespread client adoption.
|
||||
*
|
||||
* @see AuthenticationUtil
|
||||
* @see BaseAccountAuthenticator
|
||||
*/
|
||||
public class BasicCredentialAuthenticationInterceptor implements ServerInterceptor {
|
||||
|
||||
private final BaseAccountAuthenticator baseAccountAuthenticator;
|
||||
|
||||
@VisibleForTesting
|
||||
static final Metadata.Key<String> BASIC_CREDENTIALS =
|
||||
Metadata.Key.of("x-signal-basic-auth-credentials", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
private static final Metadata EMPTY_TRAILERS = new Metadata();
|
||||
|
||||
public BasicCredentialAuthenticationInterceptor(final BaseAccountAuthenticator baseAccountAuthenticator) {
|
||||
this.baseAccountAuthenticator = baseAccountAuthenticator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
|
||||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
final String credentialString = headers.get(BASIC_CREDENTIALS);
|
||||
|
||||
if (StringUtils.isNotBlank(credentialString)) {
|
||||
try {
|
||||
final BasicCredentials credentials = extractBasicCredentials(credentialString);
|
||||
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
|
||||
baseAccountAuthenticator.authenticate(credentials, false);
|
||||
|
||||
if (maybeAuthenticatedAccount.isPresent()) {
|
||||
final AuthenticatedAccount authenticatedAccount = maybeAuthenticatedAccount.get();
|
||||
|
||||
final Context context = Context.current()
|
||||
.withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY, authenticatedAccount.getAccount().getUuid())
|
||||
.withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY, authenticatedAccount.getAuthenticatedDevice().getId());
|
||||
|
||||
return Contexts.interceptCall(context, call, headers, next);
|
||||
} else {
|
||||
call.close(Status.UNAUTHENTICATED.withDescription("Credentials not accepted"), EMPTY_TRAILERS);
|
||||
}
|
||||
} catch (final IllegalArgumentException e) {
|
||||
call.close(Status.UNAUTHENTICATED.withDescription("Could not parse credentials"), EMPTY_TRAILERS);
|
||||
}
|
||||
} else {
|
||||
call.close(Status.UNAUTHENTICATED.withDescription("No credentials provided"), EMPTY_TRAILERS);
|
||||
}
|
||||
|
||||
return new ServerCall.Listener<>() {};
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static BasicCredentials extractBasicCredentials(final String credentials) {
|
||||
if (credentials.indexOf(':') < 0) {
|
||||
throw new IllegalArgumentException("Credentials do not include a username and password part");
|
||||
}
|
||||
|
||||
final String[] pieces = credentials.split(":", 2);
|
||||
|
||||
return new BasicCredentials(pieces[0], pieces[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
|
||||
/**
|
||||
* A "prohibit authentication" interceptor ensures that requests to endpoints that should be invoked anonymously do not
|
||||
* contain an authorization header in the request metdata. Calls with an associated authenticated device are closed with
|
||||
* an {@code UNAUTHENTICATED} status.
|
||||
*/
|
||||
public class ProhibitAuthenticationInterceptor implements ServerInterceptor {
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
|
||||
final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
|
||||
final String authHeaderString = headers.get(Metadata.Key.of(RequireAuthenticationInterceptor.AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
|
||||
if (authHeaderString != null) {
|
||||
call.close(Status.UNAUTHENTICATED.withDescription("authorization header forbidden"), new Metadata());
|
||||
return new ServerCall.Listener<>() {};
|
||||
}
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import io.grpc.Context;
|
||||
import io.grpc.Contexts;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
/**
|
||||
* A "require authentication" interceptor authenticates requests and attaches the {@link AuthenticatedDevice} to the
|
||||
* current gRPC context. Calls without authentication or with invalid credentials are closed with an
|
||||
* {@code UNAUTHENTICATED} status. If a call's authentication status cannot be determined (i.e. because the accounts
|
||||
* database is unavailable), the interceptor will reject the call with a status of {@code UNAVAILABLE}.
|
||||
*/
|
||||
public class RequireAuthenticationInterceptor implements ServerInterceptor {
|
||||
|
||||
static final String AUTHORIZATION_HEADER = "authorization";
|
||||
|
||||
private final AccountAuthenticator authenticator;
|
||||
|
||||
public RequireAuthenticationInterceptor(final AccountAuthenticator authenticator) {
|
||||
this.authenticator = authenticator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
|
||||
final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
|
||||
final String authHeaderString = headers.get(
|
||||
Metadata.Key.of(AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
|
||||
|
||||
if (authHeaderString == null) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call,
|
||||
Status.UNAUTHENTICATED.withDescription("missing authorization header"));
|
||||
}
|
||||
|
||||
final Optional<BasicCredentials> basicCredentials = HeaderUtils.basicCredentialsFromAuthHeader(authHeaderString);
|
||||
if (basicCredentials.isEmpty()) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call,
|
||||
Status.UNAUTHENTICATED.withDescription("malformed authorization header"));
|
||||
}
|
||||
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> authenticated =
|
||||
authenticator.authenticate(basicCredentials.get());
|
||||
if (authenticated.isEmpty()) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call,
|
||||
Status.UNAUTHENTICATED.withDescription("invalid credentials"));
|
||||
}
|
||||
|
||||
final AuthenticatedDevice authenticatedDevice = new AuthenticatedDevice(
|
||||
authenticated.get().accountIdentifier(),
|
||||
authenticated.get().deviceId());
|
||||
|
||||
return Contexts.interceptCall(Context.current()
|
||||
.withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE, authenticatedDevice),
|
||||
call, headers, next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import io.grpc.Status;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.stream.StreamSupport;
|
||||
import javax.annotation.Nullable;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
/**
|
||||
* Issues ZK backup auth credentials for authenticated accounts
|
||||
* <p>
|
||||
* Authenticated callers can create ZK credentials that contain a blinded backup-id, so that they can later use that
|
||||
* backup id without the verifier learning that the id is associated with this account.
|
||||
* <p>
|
||||
* First use {@link #commitBackupId} to provide a blinded backup-id. This is stored in durable storage. Then the caller
|
||||
* can use {@link #getBackupAuthCredentials} to retrieve credentials that can subsequently be used to make anonymously
|
||||
* authenticated requests against their backup-id.
|
||||
*/
|
||||
public class BackupAuthManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupAuthManager.class);
|
||||
|
||||
|
||||
final static Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
|
||||
final static String BACKUP_MEDIA_EXPERIMENT_NAME = "backupMedia";
|
||||
|
||||
private final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||
private final GenericServerSecretParams serverSecretParams;
|
||||
private final ServerZkReceiptOperations serverZkReceiptOperations;
|
||||
private final RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
private final Clock clock;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
public BackupAuthManager(
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final AccountsManager accountsManager,
|
||||
final ServerZkReceiptOperations serverZkReceiptOperations,
|
||||
final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||
final GenericServerSecretParams serverSecretParams,
|
||||
final Clock clock) {
|
||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.accountsManager = accountsManager;
|
||||
this.serverZkReceiptOperations = serverZkReceiptOperations;
|
||||
this.redeemedReceiptsManager = redeemedReceiptsManager;
|
||||
this.serverSecretParams = serverSecretParams;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store credential requests containing blinded backup-ids for future use.
|
||||
*
|
||||
* @param account The account using the backup-id
|
||||
* @param device The device setting the account backup-id
|
||||
* @param messagesBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
|
||||
* message backups
|
||||
* @param mediaBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
|
||||
* media backups
|
||||
* @return A future that completes when the credentialRequest has been stored
|
||||
* @throws RateLimitExceededException If too many backup-ids have been committed
|
||||
*/
|
||||
public CompletableFuture<Void> commitBackupId(
|
||||
final Account account,
|
||||
final Device device,
|
||||
final Optional<BackupAuthCredentialRequest> messagesBackupCredentialRequest,
|
||||
final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest) {
|
||||
if (!device.isPrimary()) {
|
||||
throw Status.PERMISSION_DENIED.withDescription("Only primary device can set backup-id").asRuntimeException();
|
||||
}
|
||||
|
||||
if (messagesBackupCredentialRequest.isEmpty() && mediaBackupCredentialRequest.isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Must set at least one of message/media credential requests")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
final byte[] storedMessageCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
|
||||
.orElse(null);
|
||||
final byte[] storedMediaCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)
|
||||
.orElse(null);
|
||||
|
||||
// If the provided credential request is null, we want to set to the existing request
|
||||
final byte[] targetMessageCredentialRequest = messagesBackupCredentialRequest
|
||||
.map(BackupAuthCredentialRequest::serialize)
|
||||
.orElse(storedMessageCredentialRequest);
|
||||
final byte[] targetMediaCredentialRequest = mediaBackupCredentialRequest
|
||||
.map(BackupAuthCredentialRequest::serialize)
|
||||
.orElse(storedMediaCredentialRequest);
|
||||
|
||||
final boolean requiresMessageRotation =
|
||||
!MessageDigest.isEqual(targetMessageCredentialRequest, storedMessageCredentialRequest);
|
||||
final boolean requiresMediaRotation =
|
||||
!MessageDigest.isEqual(targetMediaCredentialRequest, storedMediaCredentialRequest);
|
||||
|
||||
if (!requiresMessageRotation && !requiresMediaRotation) {
|
||||
// No need to update or enforce rate limits, this is the credential that the user has already
|
||||
// committed to.
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
CompletableFuture<Void> rateLimitFuture = CompletableFuture.completedFuture(null);
|
||||
|
||||
if (requiresMessageRotation) {
|
||||
rateLimitFuture = rateLimitFuture.thenCombine(
|
||||
rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validateAsync(account.getUuid()),
|
||||
(_, _) -> null);
|
||||
}
|
||||
|
||||
if (requiresMediaRotation && hasActiveVoucher(account)) {
|
||||
rateLimitFuture = rateLimitFuture.thenCombine(
|
||||
rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID).validateAsync(account.getUuid()),
|
||||
(_, _) -> null);
|
||||
}
|
||||
|
||||
return rateLimitFuture.thenCompose(ignored -> this.accountsManager
|
||||
.updateAsync(account, a ->
|
||||
a.setBackupCredentialRequests(targetMessageCredentialRequest, targetMediaCredentialRequest))
|
||||
.thenRun(Util.NOOP))
|
||||
.toCompletableFuture();
|
||||
}
|
||||
|
||||
public record BackupIdRotationLimit(boolean hasPermitsRemaining, Duration nextPermitAvailable) {}
|
||||
|
||||
public CompletionStage<BackupIdRotationLimit> checkBackupIdRotationLimit(final Account account) {
|
||||
final RateLimiter messagesLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID);
|
||||
final RateLimiter mediaLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID);
|
||||
|
||||
final boolean isPaid = hasActiveVoucher(account);
|
||||
|
||||
final CompletionStage<Boolean> hasSetMessagesPermits =
|
||||
messagesLimiter.hasAvailablePermitsAsync(account.getUuid(), 1);
|
||||
final CompletionStage<Boolean> hasSetMediaPermits = isPaid
|
||||
? mediaLimiter.hasAvailablePermitsAsync(account.getUuid(), 1)
|
||||
: CompletableFuture.completedFuture(true);
|
||||
|
||||
return hasSetMessagesPermits.thenCombine(hasSetMediaPermits, (hasMessage, hasMedia) -> {
|
||||
if (hasMedia && hasMessage) {
|
||||
return new BackupIdRotationLimit(true, Duration.ZERO);
|
||||
} else {
|
||||
final Duration timeToNextPermit = Collections.max(Arrays.asList(
|
||||
messagesLimiter.config().permitRegenerationDuration(),
|
||||
isPaid ? mediaLimiter.config().permitRegenerationDuration() : Duration.ZERO));
|
||||
return new BackupIdRotationLimit(false, timeToNextPermit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {}
|
||||
|
||||
/**
|
||||
* Create a credential for every day between redemptionStart and redemptionEnd
|
||||
* <p>
|
||||
* This uses a {@link BackupAuthCredentialRequest} previous stored via {@link this#commitBackupId} to generate the
|
||||
* credentials.
|
||||
* <p>
|
||||
* If the account has a BackupVoucher allowing access to paid backups, credentials with a redemptionTime before the
|
||||
* voucher's expiration will include paid backup access. If the BackupVoucher exists but is already expired, this
|
||||
* method will also remove the expired voucher from the account.
|
||||
*
|
||||
* @param account The account to create the credentials for
|
||||
* @param credentialType The type of backup credentials to create
|
||||
* @param redemptionRange The time range to return credentials for
|
||||
* @return Credentials and the day on which they may be redeemed
|
||||
*/
|
||||
public CompletableFuture<List<Credential>> getBackupAuthCredentials(
|
||||
final Account account,
|
||||
final BackupCredentialType credentialType,
|
||||
final RedemptionRange redemptionRange) {
|
||||
|
||||
// If the account has an expired payment, clear it before continuing
|
||||
if (hasExpiredVoucher(account)) {
|
||||
return accountsManager.updateAsync(account, a -> {
|
||||
// Re-check in case we raced with an update
|
||||
if (hasExpiredVoucher(a)) {
|
||||
a.setBackupVoucher(null);
|
||||
}
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, credentialType, redemptionRange));
|
||||
}
|
||||
|
||||
// fetch the blinded backup-id the account should have previously committed to
|
||||
final byte[] committedBytes = account.getBackupCredentialRequest(credentialType)
|
||||
.orElseThrow(() -> Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException());
|
||||
|
||||
try {
|
||||
final BackupLevel defaultBackupLevel = configuredBackupLevel(account);
|
||||
|
||||
// create a credential for every day in the requested period
|
||||
final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);
|
||||
return CompletableFuture.completedFuture(StreamSupport.stream(redemptionRange.spliterator(), false)
|
||||
.map(redemptionTime -> {
|
||||
// Check if the account has a voucher that's good for a certain receiptLevel at redemption time, otherwise
|
||||
// use the default receipt level
|
||||
final BackupLevel backupLevel = storedBackupLevel(account, redemptionTime).orElse(defaultBackupLevel);
|
||||
return new Credential(
|
||||
credentialReq.issueCredential(redemptionTime, backupLevel, credentialType, serverSecretParams),
|
||||
redemptionTime);
|
||||
})
|
||||
.toList());
|
||||
} catch (InvalidInputException e) {
|
||||
throw Status.INTERNAL
|
||||
.withDescription("Could not deserialize stored request credential")
|
||||
.withCause(e)
|
||||
.asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a receipt to enable paid backups on the account.
|
||||
*
|
||||
* @param account The account to enable backups on
|
||||
* @param receiptCredentialPresentation A ZK receipt presentation proving payment
|
||||
* @return A future that completes successfully when the account has been updated
|
||||
*/
|
||||
public CompletableFuture<Void> redeemReceipt(
|
||||
final Account account,
|
||||
final ReceiptCredentialPresentation receiptCredentialPresentation) {
|
||||
try {
|
||||
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
|
||||
} catch (VerificationFailedException e) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("receipt credential presentation verification failed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
|
||||
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
|
||||
if (clock.instant().isAfter(receiptExpiration)) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("receipt is already expired").asRuntimeException();
|
||||
}
|
||||
|
||||
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
|
||||
|
||||
if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.PAID) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("server does not recognize the requested receipt level")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
if (account.getBackupCredentialRequest(BackupCredentialType.MEDIA).isEmpty()) {
|
||||
throw Status.ABORTED
|
||||
.withDescription("account must have a backup-id commitment")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
return redeemedReceiptsManager
|
||||
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid())
|
||||
.thenCompose(receiptAllowed -> {
|
||||
if (!receiptAllowed) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("receipt serial is already redeemed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the duration of the backup voucher on an account.
|
||||
*
|
||||
* @param account The account to update
|
||||
* @param backupVoucher The backup voucher to apply to this account
|
||||
* @return A future that completes once the account has been updated to have at least the level and expiration
|
||||
* in the provided voucher.
|
||||
*/
|
||||
public CompletableFuture<Void> extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {
|
||||
return accountsManager.updateAsync(account, a -> {
|
||||
// Receipt credential expirations must be day aligned. Make sure any manually set backupVoucher is also day
|
||||
// aligned
|
||||
final Account.BackupVoucher newPayment = new Account.BackupVoucher(
|
||||
backupVoucher.receiptLevel(),
|
||||
backupVoucher.expiration().truncatedTo(ChronoUnit.DAYS));
|
||||
final Account.BackupVoucher existingPayment = a.getBackupVoucher();
|
||||
a.setBackupVoucher(merge(existingPayment, newPayment));
|
||||
}).thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
private static Account.BackupVoucher merge(@Nullable final Account.BackupVoucher prev,
|
||||
final Account.BackupVoucher next) {
|
||||
if (prev == null) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (next.receiptLevel() != prev.receiptLevel()) {
|
||||
return next;
|
||||
}
|
||||
|
||||
// If the new payment has the same receipt level as the old, select the further out of the two expiration times
|
||||
if (prev.expiration().isAfter(next.expiration())) {
|
||||
// This should be fairly rare, either a client reused an old receipt or we reduced the validity period
|
||||
logger.warn(
|
||||
"Redeemed receipt with an expiration at {} when we've previously had a redemption with a later expiration {}",
|
||||
next.expiration(), prev.expiration());
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private boolean hasActiveVoucher(final Account account) {
|
||||
return account.getBackupVoucher() != null && clock.instant().isBefore(account.getBackupVoucher().expiration());
|
||||
}
|
||||
|
||||
private boolean hasExpiredVoucher(final Account account) {
|
||||
return account.getBackupVoucher() != null && !hasActiveVoucher(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the receipt level stored in the {@link Account.BackupVoucher} on the account if it's present and not expired.
|
||||
*
|
||||
* @param account The account to check
|
||||
* @param redemptionTime The time to check against the expiration time
|
||||
* @return The receipt level on the backup voucher, or empty if the account does not have one or it is expired
|
||||
*/
|
||||
private Optional<BackupLevel> storedBackupLevel(final Account account, final Instant redemptionTime) {
|
||||
return Optional.ofNullable(account.getBackupVoucher())
|
||||
.filter(backupVoucher -> !redemptionTime.isAfter(backupVoucher.expiration()))
|
||||
.map(Account.BackupVoucher::receiptLevel)
|
||||
.map(BackupLevelUtil::fromReceiptLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backup receipt level that should be used by default for this account determined via configuration.
|
||||
*
|
||||
* @param account the account to check
|
||||
* @return The default receipt level that should be used for the account if the account does not have a
|
||||
* BackupVoucher.
|
||||
*/
|
||||
private BackupLevel configuredBackupLevel(final Account account) {
|
||||
return this.experimentEnrollmentManager.isEnrolled(account.getUuid(), BACKUP_MEDIA_EXPERIMENT_NAME)
|
||||
? BackupLevel.PAID
|
||||
: BackupLevel.FREE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
|
||||
public class BackupLevelUtil {
|
||||
public static BackupLevel fromReceiptLevel(long receiptLevel) {
|
||||
try {
|
||||
return BackupLevel.fromValue(Math.toIntExact(receiptLevel));
|
||||
} catch (ArithmeticException e) {
|
||||
throw new IllegalArgumentException("Invalid receipt level: " + receiptLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.util.DataSize;
|
||||
import io.grpc.Status;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicBackupConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.AsyncTimerUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class BackupManager {
|
||||
|
||||
static final String MESSAGE_BACKUP_NAME = "messageBackup";
|
||||
public static final long MAX_MESSAGE_BACKUP_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();
|
||||
public static final long MAX_MEDIA_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();
|
||||
|
||||
private static final String ZK_AUTHN_COUNTER_NAME = MetricsUtil.name(BackupManager.class, "authentication");
|
||||
private static final String ZK_AUTHZ_FAILURE_COUNTER_NAME = MetricsUtil.name(BackupManager.class,
|
||||
"authorizationFailure");
|
||||
private static final String USAGE_RECALCULATION_COUNTER_NAME = MetricsUtil.name(BackupManager.class,
|
||||
"usageRecalculation");
|
||||
private static final String DELETE_COUNT_DISTRIBUTION_NAME = MetricsUtil.name(BackupManager.class,
|
||||
"deleteCount");
|
||||
private static final Timer SYNCHRONOUS_DELETE_TIMER =
|
||||
Metrics.timer(MetricsUtil.name(BackupManager.class, "synchronousDelete"));
|
||||
|
||||
private static final String NUM_OBJECTS_SUMMARY_NAME = MetricsUtil.name(BackupManager.class, "numObjects");
|
||||
private static final String BYTES_USED_SUMMARY_NAME = MetricsUtil.name(BackupManager.class, "bytesUsed");
|
||||
|
||||
private static final String SUCCESS_TAG_NAME = "success";
|
||||
private static final String FAILURE_REASON_TAG_NAME = "reason";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BackupManager.class);
|
||||
|
||||
private final BackupsDb backupsDb;
|
||||
private final GenericServerSecretParams serverSecretParams;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final TusAttachmentGenerator tusAttachmentGenerator;
|
||||
private final Cdn3BackupCredentialGenerator cdn3BackupCredentialGenerator;
|
||||
private final RemoteStorageManager remoteStorageManager;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final ExternalServiceCredentialsGenerator secureValueRecoveryBCredentialsGenerator;
|
||||
private final SecureValueRecoveryClient secureValueRecoveryBClient;
|
||||
private final Clock clock;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
public BackupManager(
|
||||
final BackupsDb backupsDb,
|
||||
final GenericServerSecretParams serverSecretParams,
|
||||
final RateLimiters rateLimiters,
|
||||
final TusAttachmentGenerator tusAttachmentGenerator,
|
||||
final Cdn3BackupCredentialGenerator cdn3BackupCredentialGenerator,
|
||||
final RemoteStorageManager remoteStorageManager,
|
||||
final ExternalServiceCredentialsGenerator secureValueRecoveryBCredentialsGenerator,
|
||||
final SecureValueRecoveryClient secureValueRecoveryBClient,
|
||||
final Clock clock,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.backupsDb = backupsDb;
|
||||
this.serverSecretParams = serverSecretParams;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.tusAttachmentGenerator = tusAttachmentGenerator;
|
||||
this.cdn3BackupCredentialGenerator = cdn3BackupCredentialGenerator;
|
||||
this.remoteStorageManager = remoteStorageManager;
|
||||
this.secureValueRecoveryBClient = secureValueRecoveryBClient;
|
||||
this.clock = clock;
|
||||
this.secureValueRecoveryBCredentialsGenerator = secureValueRecoveryBCredentialsGenerator;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the public key for the backup-id.
|
||||
* <p>
|
||||
* Once set, calls {@link BackupManager#authenticateBackupUser} can succeed if the presentation is signed with the
|
||||
* private key corresponding to this public key.
|
||||
*
|
||||
* @param presentation a ZK credential presentation that encodes the backupId
|
||||
* @param signature the signature of the presentation
|
||||
* @param publicKey the public key of a key-pair that the presentation must be signed with
|
||||
*/
|
||||
public CompletableFuture<Void> setPublicKey(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature,
|
||||
final ECPublicKey publicKey) {
|
||||
|
||||
// Note: this is a special case where we can't validate the presentation signature against the stored public key
|
||||
// because we are currently setting it. We check against the provided public key, but we must also verify that
|
||||
// there isn't an existing, different stored public key for the backup-id (verified with a condition expression)
|
||||
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
|
||||
verifyPresentation(presentation).verifySignature(signature, publicKey);
|
||||
|
||||
return backupsDb.setPublicKey(presentation.getBackupId(), credentialTypeAndBackupLevel.second(), publicKey)
|
||||
.exceptionally(ExceptionUtils.exceptionallyHandler(PublicKeyConflictException.class, ex -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "public_key_conflict")
|
||||
.increment();
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("public key does not match existing public key for the backup-id")
|
||||
.asRuntimeException();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form that may be used to upload a backup file for the backupId encoded in the presentation.
|
||||
* <p>
|
||||
* If successful, this also updates the TTL of the backup.
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @return the upload form
|
||||
*/
|
||||
public CompletableFuture<BackupUploadDescriptor> createMessageBackupUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);
|
||||
|
||||
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
|
||||
return backupsDb
|
||||
.addMessageBackup(backupUser)
|
||||
.thenApply(_ ->
|
||||
cdn3BackupCredentialGenerator.generateUpload(cdnMessageBackupName(backupUser)));
|
||||
}
|
||||
|
||||
public CompletableFuture<BackupUploadDescriptor> createTemporaryAttachmentUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
return rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT)
|
||||
.validateAsync(rateLimitKey(backupUser)).thenApply(ignored -> {
|
||||
final byte[] bytes = new byte[15];
|
||||
secureRandom.nextBytes(bytes);
|
||||
final String attachmentKey = Base64.getUrlEncoder().encodeToString(bytes);
|
||||
final AttachmentGenerator.Descriptor descriptor = tusAttachmentGenerator.generateAttachment(attachmentKey);
|
||||
return new BackupUploadDescriptor(3, attachmentKey, descriptor.headers(), descriptor.signedUploadLocation());
|
||||
}).toCompletableFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last update timestamps for the backupId in the presentation
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
*/
|
||||
public CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
// update message backup TTL
|
||||
return backupsDb.ttlRefresh(backupUser).thenAccept(storedBackupAttributes -> {
|
||||
if (backupUser.credentialType() == BackupCredentialType.MEDIA) {
|
||||
final long maxTotalMediaSize =
|
||||
dynamicConfigurationManager.getConfiguration().getBackupConfiguration().maxTotalMediaSize();
|
||||
|
||||
// Report that the backup is out of quota if it cannot store a max size media object
|
||||
final boolean quotaExhausted = storedBackupAttributes.bytesUsed() >=
|
||||
(maxTotalMediaSize - BackupManager.MAX_MEDIA_OBJECT_SIZE);
|
||||
|
||||
final Tags tags = Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of("type", backupUser.credentialType().name()),
|
||||
Tag.of("tier", backupUser.backupLevel().name()),
|
||||
Tag.of("quotaExhausted", String.valueOf(quotaExhausted)));
|
||||
|
||||
DistributionSummary.builder(NUM_OBJECTS_SUMMARY_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(storedBackupAttributes.numObjects());
|
||||
DistributionSummary.builder(BYTES_USED_SUMMARY_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(storedBackupAttributes.bytesUsed());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public record BackupInfo(int cdn, String backupSubdir, String mediaSubdir, String messageBackupKey,
|
||||
Optional<Long> mediaUsedSpace) {}
|
||||
|
||||
/**
|
||||
* Retrieve information about the existing backup
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @return Information about the existing backup
|
||||
*/
|
||||
public CompletableFuture<BackupInfo> backupInfo(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return backupsDb.describeBackup(backupUser)
|
||||
.thenApply(backupDescription -> new BackupInfo(
|
||||
backupDescription.cdn(),
|
||||
backupUser.backupDir(),
|
||||
backupUser.mediaDir(),
|
||||
MESSAGE_BACKUP_NAME,
|
||||
backupDescription.mediaUsedSpace()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an encrypted object to the backup cdn, adding a layer of encryption
|
||||
* <p>
|
||||
* Implementation notes: <p> This method guarantees that any object that gets successfully copied to the backup cdn
|
||||
* will also be deducted from the user's quota. </p>
|
||||
* <p>
|
||||
* However, the converse isn't true. It's possible we may charge the user for media they failed to copy. As a result,
|
||||
* the quota may be over reported. It should be recalculated before taking quota enforcement actions.
|
||||
*
|
||||
* @return A Flux that emits the locations of the double-encrypted objects on the backup cdn, or includes an error
|
||||
* detailing why the object could not be copied.
|
||||
*/
|
||||
public Flux<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, List<CopyParameters> toCopy) {
|
||||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
final DynamicBackupConfiguration backupConfiguration =
|
||||
dynamicConfigurationManager.getConfiguration().getBackupConfiguration();
|
||||
|
||||
return Mono.fromFuture(() -> allowedCopies(backupUser, toCopy))
|
||||
.flatMapMany(quotaResult -> Flux.concat(
|
||||
|
||||
// Perform copies for requests that fit in our quota, first updating the usage. If the copy fails, our
|
||||
// estimated quota usage may not be exact since we update usage first. We make a best-effort attempt
|
||||
// to undo the usage update if we know that the copied failed for sure.
|
||||
Flux.fromIterable(quotaResult.requestsToCopy())
|
||||
|
||||
// Update the usage in reasonable chunk sizes to bound how out of sync our claimed and actual usage gets
|
||||
.buffer(backupConfiguration.usageCheckpointCount())
|
||||
.concatMap(copyParameters -> {
|
||||
final long quotaToConsume = copyParameters.stream()
|
||||
.mapToLong(CopyParameters::destinationObjectSize)
|
||||
.sum();
|
||||
return Mono
|
||||
.fromFuture(backupsDb.trackMedia(backupUser, copyParameters.size(), quotaToConsume))
|
||||
.thenMany(Flux.fromIterable(copyParameters));
|
||||
})
|
||||
|
||||
// Actually perform the copies now that we've updated the quota
|
||||
.flatMapSequential(copyParams -> copyToBackup(backupUser, copyParams)
|
||||
.flatMap(copyResult -> switch (copyResult.outcome()) {
|
||||
case SUCCESS -> Mono.just(copyResult);
|
||||
case SOURCE_WRONG_LENGTH, SOURCE_NOT_FOUND, OUT_OF_QUOTA -> Mono
|
||||
.fromFuture(this.backupsDb.trackMedia(backupUser, -1, -copyParams.destinationObjectSize()))
|
||||
.thenReturn(copyResult);
|
||||
}),
|
||||
backupConfiguration.copyConcurrency(), 1),
|
||||
|
||||
// There wasn't enough quota remaining to perform these copies
|
||||
Flux.fromIterable(quotaResult.requestsToReject())
|
||||
.map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null))
|
||||
));
|
||||
}
|
||||
|
||||
private Mono<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, final CopyParameters copyParameters) {
|
||||
return Mono.fromCompletionStage(() -> remoteStorageManager.copy(
|
||||
copyParameters.sourceCdn(), copyParameters.sourceKey(), copyParameters.sourceLength(),
|
||||
copyParameters.encryptionParameters(),
|
||||
cdnMediaPath(backupUser, copyParameters.destinationMediaId())))
|
||||
|
||||
// Successfully copied!
|
||||
.thenReturn(new CopyResult(
|
||||
CopyResult.Outcome.SUCCESS, copyParameters.destinationMediaId(), remoteStorageManager.cdnNumber()))
|
||||
|
||||
// Otherwise, squash per-item copy errors that don't fail the entire operation
|
||||
.onErrorResume(
|
||||
// If the error maps to an explicit result type
|
||||
throwable ->
|
||||
CopyResult.fromCopyError(throwable, copyParameters.destinationMediaId()).isPresent(),
|
||||
// return that result type instead of propagating the error
|
||||
throwable ->
|
||||
Mono.just(CopyResult.fromCopyError(throwable, copyParameters.destinationMediaId()).orElseThrow()));
|
||||
}
|
||||
|
||||
private record QuotaResult(List<CopyParameters> requestsToCopy, List<CopyParameters> requestsToReject) {}
|
||||
|
||||
/**
|
||||
* Determine which copy requests can be performed with the user's remaining quota. This does not update the quota.
|
||||
*
|
||||
* @param backupUser The user quota to check against
|
||||
* @param toCopy The proposed copy requests
|
||||
* @return list of QuotaResult indicating which requests fit into the remaining quota and which requests should be
|
||||
* rejected with {@link CopyResult.Outcome#OUT_OF_QUOTA}
|
||||
*/
|
||||
private CompletableFuture<QuotaResult> allowedCopies(
|
||||
final AuthenticatedBackupUser backupUser,
|
||||
final List<CopyParameters> toCopy) {
|
||||
final long totalBytesAdded = toCopy.stream()
|
||||
.mapToLong(copyParameters -> {
|
||||
if (copyParameters.sourceLength() > MAX_MEDIA_OBJECT_SIZE || copyParameters.sourceLength() < 0) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Invalid sourceObject size")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return copyParameters.destinationObjectSize();
|
||||
})
|
||||
.sum();
|
||||
|
||||
final DynamicBackupConfiguration backupConfiguration =
|
||||
dynamicConfigurationManager.getConfiguration().getBackupConfiguration();
|
||||
final Duration maxQuotaStaleness = backupConfiguration.maxQuotaStaleness();
|
||||
final long maxTotalMediaSize = backupConfiguration.maxTotalMediaSize();
|
||||
|
||||
return backupsDb.getMediaUsage(backupUser)
|
||||
.thenComposeAsync(info -> {
|
||||
long remainingQuota = maxTotalMediaSize - info.usageInfo().bytesUsed();
|
||||
final boolean canStore = remainingQuota >= totalBytesAdded;
|
||||
if (canStore || info.lastRecalculationTime().isAfter(clock.instant().minus(maxQuotaStaleness))) {
|
||||
return CompletableFuture.completedFuture(remainingQuota);
|
||||
}
|
||||
|
||||
// The user is out of quota, and we have not recently recalculated the user's usage. Double check by doing a
|
||||
// hard recalculation before actually forbidding the user from storing additional media.
|
||||
return this.remoteStorageManager.calculateBytesUsed(cdnMediaDirectory(backupUser))
|
||||
.thenCompose(usage -> backupsDb
|
||||
.setMediaUsage(backupUser, usage)
|
||||
.thenApply(ignored -> usage))
|
||||
.whenComplete((newUsage, throwable) -> {
|
||||
boolean usageChanged = throwable == null && !newUsage.equals(info.usageInfo());
|
||||
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of("usageChanged", String.valueOf(usageChanged))))
|
||||
.increment();
|
||||
})
|
||||
.thenApply(newUsage -> maxTotalMediaSize - newUsage.bytesUsed());
|
||||
})
|
||||
.thenApply(remainingQuota -> {
|
||||
// Figure out how many of the requested objects fit in the remaining quota
|
||||
final int index = indexWhereTotalExceeds(toCopy, CopyParameters::destinationObjectSize,
|
||||
remainingQuota);
|
||||
return new QuotaResult(toCopy.subList(0, index), toCopy.subList(index, toCopy.size()));
|
||||
});
|
||||
}
|
||||
|
||||
public record RecalculationResult(UsageInfo oldUsage, UsageInfo newUsage) {}
|
||||
public CompletionStage<Optional<RecalculationResult>> recalculateQuota(final StoredBackupAttributes storedBackupAttributes) {
|
||||
if (StringUtils.isBlank(storedBackupAttributes.backupDir()) || StringUtils.isBlank(storedBackupAttributes.mediaDir())) {
|
||||
return CompletableFuture.completedFuture(Optional.empty());
|
||||
}
|
||||
final String cdnPath = cdnMediaDirectory(storedBackupAttributes.backupDir(), storedBackupAttributes.mediaDir());
|
||||
return this.remoteStorageManager.calculateBytesUsed(cdnPath).thenCompose(usage ->
|
||||
backupsDb.setMediaUsage(storedBackupAttributes, usage).thenApply(ignored ->
|
||||
Optional.of(new RecalculationResult(
|
||||
new UsageInfo(storedBackupAttributes.bytesUsed(), storedBackupAttributes.numObjects()),
|
||||
usage))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the largest index i such that sum(ts[0],...ts[i - 1]) <= max
|
||||
*/
|
||||
private static <T> int indexWhereTotalExceeds(List<T> ts, Function<T, Long> valueFunction, long max) {
|
||||
long sum = 0;
|
||||
for (int index = 0; index < ts.size(); index++) {
|
||||
sum += valueFunction.apply(ts.get(index));
|
||||
if (sum > max) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return ts.size();
|
||||
}
|
||||
|
||||
|
||||
public record StorageDescriptor(int cdn, byte[] key) {}
|
||||
|
||||
public record StorageDescriptorWithLength(int cdn, byte[] key, long length) {}
|
||||
|
||||
/**
|
||||
* Generate credentials that can be used to read from the backup CDN
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @param cdnNumber the cdn number to get backup credentials for
|
||||
* @return A map of headers to include with CDN requests
|
||||
*/
|
||||
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser, final int cdnNumber) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
if (cdnNumber != 3) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("unknown cdn").asRuntimeException();
|
||||
}
|
||||
return cdn3BackupCredentialGenerator.readHeaders(backupUser.backupDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate credentials that can be used with SVRB
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @return the credential that may be used with SVRB
|
||||
*/
|
||||
public ExternalServiceCredentials generateSvrbAuth(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
// Clients may only use SVRB with their messages backup-id
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);
|
||||
return secureValueRecoveryBCredentialsGenerator.generateFor(svrbIdentifier(backupUser));
|
||||
}
|
||||
|
||||
private static String svrbIdentifier(final AuthenticatedBackupUser backupUser) {
|
||||
return svrbIdentifier(BackupsDb.hashedBackupId(backupUser.backupId()));
|
||||
}
|
||||
|
||||
private static String svrbIdentifier(final byte[] hashedBackupId) {
|
||||
return HexFormat.of().formatHex(hashedBackupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List of media stored for a particular backup id
|
||||
*
|
||||
* @param media A page of media entries
|
||||
* @param cursor If set, can be passed back to a subsequent list request to resume listing from the previous point
|
||||
*/
|
||||
public record ListMediaResult(List<StorageDescriptorWithLength> media, Optional<String> cursor) {}
|
||||
|
||||
/**
|
||||
* List the media stored by the backupUser
|
||||
*
|
||||
* @param backupUser An already ZK authenticated backup user
|
||||
* @param cursor A cursor returned by a previous call that can be used to resume listing
|
||||
* @param limit The maximum number of list results to return
|
||||
* @return A {@link ListMediaResult}
|
||||
*/
|
||||
public CompletionStage<ListMediaResult> list(
|
||||
final AuthenticatedBackupUser backupUser,
|
||||
final Optional<String> cursor,
|
||||
final int limit) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit)
|
||||
.thenApply(result ->
|
||||
new ListMediaResult(
|
||||
result
|
||||
.objects()
|
||||
.stream()
|
||||
.map(entry -> new StorageDescriptorWithLength(
|
||||
remoteStorageManager.cdnNumber(),
|
||||
decodeMediaIdFromCdn(entry.key()),
|
||||
entry.length()
|
||||
))
|
||||
.toList(),
|
||||
result.cursor()
|
||||
));
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> deleteEntireBackup(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
|
||||
final int deletionConcurrency =
|
||||
dynamicConfigurationManager.getConfiguration().getBackupConfiguration().deletionConcurrency();
|
||||
|
||||
// Clients only include SVRB data with their messages backup-id
|
||||
final CompletableFuture<Void> svrbRemoval = switch(backupUser.credentialType()) {
|
||||
case BackupCredentialType.MESSAGES -> secureValueRecoveryBClient.removeData(svrbIdentifier(backupUser));
|
||||
case BackupCredentialType.MEDIA -> CompletableFuture.completedFuture(null);
|
||||
};
|
||||
return svrbRemoval.thenCompose(_ -> backupsDb
|
||||
// Try to swap out the backupDir for the user
|
||||
.scheduleBackupDeletion(backupUser)
|
||||
// If there was already a pending swap, try to delete the cdn objects directly
|
||||
.exceptionallyCompose(ExceptionUtils.exceptionallyHandler(BackupsDb.PendingDeletionException.class, e ->
|
||||
AsyncTimerUtil.record(SYNCHRONOUS_DELETE_TIMER, () ->
|
||||
deletePrefix(backupUser.backupDir(), deletionConcurrency)))));
|
||||
}
|
||||
|
||||
|
||||
public Flux<StorageDescriptor> deleteMedia(final AuthenticatedBackupUser backupUser,
|
||||
final List<StorageDescriptor> storageDescriptors) {
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
// Check for a cdn we don't know how to process
|
||||
if (storageDescriptors.stream().anyMatch(sd -> sd.cdn() != remoteStorageManager.cdnNumber())) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("unsupported media cdn provided")
|
||||
.asRuntimeException();
|
||||
}
|
||||
final DynamicBackupConfiguration backupConfiguration =
|
||||
dynamicConfigurationManager.getConfiguration().getBackupConfiguration();
|
||||
|
||||
return Flux.usingWhen(
|
||||
|
||||
// Gather usage updates into the UsageBatcher so we don't have to update our backup record on every delete
|
||||
Mono.just(new UsageBatcher(backupConfiguration.usageCheckpointCount())),
|
||||
|
||||
// Deletes the objects, returning their former location. Tracks bytes removed so the quota can be updated on
|
||||
// completion
|
||||
batcher -> Flux.fromIterable(storageDescriptors)
|
||||
|
||||
// Delete the objects, allowing DELETION_CONCURRENCY operations out at a time
|
||||
.flatMapSequential(
|
||||
sd -> Mono.fromCompletionStage(remoteStorageManager.delete(cdnMediaPath(backupUser, sd.key()))),
|
||||
backupConfiguration.deletionConcurrency())
|
||||
.zipWithIterable(storageDescriptors)
|
||||
|
||||
// Track how much the remote storage manager indicated was deleted as part of the operation
|
||||
.concatMap(deletedBytesAndStorageDescriptor -> {
|
||||
final long deletedBytes = deletedBytesAndStorageDescriptor.getT1();
|
||||
final StorageDescriptor sd = deletedBytesAndStorageDescriptor.getT2();
|
||||
|
||||
// If it has been a while, perform a checkpoint to make sure our usage doesn't drift too much
|
||||
if (batcher.update(-deletedBytes)) {
|
||||
final UsageBatcher.UsageUpdate usageUpdate = batcher.getAndReset();
|
||||
return Mono
|
||||
.fromFuture(backupsDb.trackMedia(backupUser, usageUpdate.countDelta, usageUpdate.bytesDelta))
|
||||
.doOnError(throwable ->
|
||||
log.warn("Failed to update delta {} after successful delete operation", usageUpdate, throwable))
|
||||
.thenReturn(sd);
|
||||
} else {
|
||||
return Mono.just(sd);
|
||||
}
|
||||
}),
|
||||
|
||||
// On cleanup, update the quota using whatever remaining updates were accumulated in the batcher
|
||||
batcher -> {
|
||||
final UsageBatcher.UsageUpdate update = batcher.getAndReset();
|
||||
return Mono
|
||||
.fromFuture(backupsDb.trackMedia(backupUser, update.countDelta, update.bytesDelta))
|
||||
.doOnError(throwable ->
|
||||
log.warn("Failed to update delta {} after successful delete operation", update, throwable));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track pending media usage updates. Not thread safe!
|
||||
*/
|
||||
private static class UsageBatcher {
|
||||
|
||||
private final int usageCheckpointCount;
|
||||
private long runningCountDelta = 0;
|
||||
private long runningBytesDelta = 0;
|
||||
|
||||
UsageBatcher(int usageCheckpointCount) {
|
||||
this.usageCheckpointCount = usageCheckpointCount;
|
||||
}
|
||||
|
||||
record UsageUpdate(long countDelta, long bytesDelta) {}
|
||||
|
||||
/**
|
||||
* Stage a usage update. Returns true when it is time to make a checkpoint
|
||||
*
|
||||
* @param bytesDelta The amount of bytes that should be tracked as used (or if negative, freed). If the delta is
|
||||
* non-zero, the count will also be updated.
|
||||
* @return true if we should persist the usage
|
||||
*/
|
||||
boolean update(long bytesDelta) {
|
||||
this.runningCountDelta += Long.signum(bytesDelta);
|
||||
this.runningBytesDelta += bytesDelta;
|
||||
return Math.abs(runningCountDelta) >= usageCheckpointCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current usage delta, and set the delta to 0
|
||||
* @return A {@link UsageUpdate} to apply
|
||||
*/
|
||||
UsageUpdate getAndReset() {
|
||||
final UsageUpdate update = new UsageUpdate(runningCountDelta, runningBytesDelta);
|
||||
runningCountDelta = 0;
|
||||
runningBytesDelta = 0;
|
||||
return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ECPublicKey INVALID_PUBLIC_KEY = ECKeyPair.generate().getPublicKey();
|
||||
|
||||
/**
|
||||
* Authenticate the ZK anonymous backup credential's presentation
|
||||
* <p>
|
||||
* This validates:
|
||||
* <li> The presentation was for a credential issued by the server </li>
|
||||
* <li> The credential is in its redemption window </li>
|
||||
* <li> The backup-id matches a previously committed blinded backup-id and server issued receipt level </li>
|
||||
* <li> The signature of the credential matches an existing publicKey associated with this backup-id </li>
|
||||
*
|
||||
* @param presentation A {@link BackupAuthCredentialPresentation}
|
||||
* @param signature An XEd25519 signature of the presentation bytes
|
||||
* @return On authentication success, the authenticated backup-id and backup-tier encoded in the presentation
|
||||
*/
|
||||
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature,
|
||||
final String userAgentString) {
|
||||
final PresentationSignatureVerifier signatureVerifier = verifyPresentation(presentation);
|
||||
return backupsDb
|
||||
.retrieveAuthenticationData(presentation.getBackupId())
|
||||
.thenApply(optionalAuthenticationData -> {
|
||||
final UserAgent userAgent = parseUserAgent(userAgentString);
|
||||
final BackupsDb.AuthenticationData authenticationData = optionalAuthenticationData
|
||||
.orElseGet(() -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME, Tags.of(
|
||||
Tag.of(SUCCESS_TAG_NAME, String.valueOf(false)),
|
||||
Tag.of(FAILURE_REASON_TAG_NAME, "missing_public_key"),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.increment();
|
||||
// There was no stored public key, use a bunk public key so that validation will fail
|
||||
return new BackupsDb.AuthenticationData(INVALID_PUBLIC_KEY, null, null);
|
||||
});
|
||||
|
||||
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
|
||||
signatureVerifier.verifySignature(signature, authenticationData.publicKey());
|
||||
|
||||
return new AuthenticatedBackupUser(
|
||||
presentation.getBackupId(),
|
||||
credentialTypeAndBackupLevel.first(),
|
||||
credentialTypeAndBackupLevel.second(),
|
||||
authenticationData.backupDir(),
|
||||
authenticationData.mediaDir(),
|
||||
userAgent);
|
||||
})
|
||||
.thenApply(result -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all backups stored in the backups table
|
||||
*
|
||||
* @param segments Number of segments to read in parallel from the underlying backup database
|
||||
* @return Flux of {@link StoredBackupAttributes} for each backup record in the backups table
|
||||
*/
|
||||
public Flux<StoredBackupAttributes> listBackupAttributes(final int segments) {
|
||||
return this.backupsDb.listBackupAttributes(segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all backups whose media or messages refresh timestamp are older than the provided purgeTime
|
||||
*
|
||||
* @param segments Number of segments to read in parallel from the underlying backup database
|
||||
* @param scheduler Scheduler for running downstream operations
|
||||
* @param purgeTime If a backup's last message refresh time is strictly before purgeTime, it will be marked as
|
||||
* requiring full deletion. If only the last refresh time is strictly before purgeTime, it will be
|
||||
* marked as requiring message deletion. Otherwise, it will not be included in the results.
|
||||
* @return Flux of backups that require some deletion action
|
||||
*/
|
||||
public Flux<ExpiredBackup> getExpiredBackups(final int segments, final Scheduler scheduler, final Instant purgeTime) {
|
||||
return this.backupsDb.getExpiredBackups(segments, scheduler, purgeTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete some or all of the objects associated with the backup, and update the backup database.
|
||||
*
|
||||
* @param expiredBackup The backup to expire. If the {@link ExpiredBackup} is a media expiration, only the media
|
||||
* objects will be deleted, otherwise all backup objects will be deleted
|
||||
* @return A stage that completes when the deletion operation is finished
|
||||
*/
|
||||
public CompletableFuture<Void> expireBackup(final ExpiredBackup expiredBackup) {
|
||||
// Clients only include SVRB data with their messages backup-id
|
||||
final CompletableFuture<Void> svrbRemoval = switch(expiredBackup.expirationType()) {
|
||||
case ALL -> secureValueRecoveryBClient.removeData(svrbIdentifier(expiredBackup.hashedBackupId()));
|
||||
case MEDIA, GARBAGE_COLLECTION -> CompletableFuture.completedFuture(null);
|
||||
};
|
||||
return svrbRemoval.thenCompose(_ -> backupsDb.startExpiration(expiredBackup)
|
||||
// the deletion operation is effectively single threaded -- it's expected that the caller can increase
|
||||
// concurrency by deleting more backups at once, rather than increasing concurrency deleting an individual
|
||||
// backup
|
||||
.thenCompose(ignored -> deletePrefix(expiredBackup.prefixToDelete(), 1))
|
||||
.thenCompose(ignored -> backupsDb.finishExpiration(expiredBackup)));
|
||||
}
|
||||
|
||||
/**
|
||||
* List and delete all files associated with a prefix
|
||||
*
|
||||
* @param prefixToDelete The prefix to expire.
|
||||
* @return A stage that completes when all objects with the given prefix have been deleted
|
||||
*/
|
||||
private CompletableFuture<Void> deletePrefix(final String prefixToDelete, int concurrentDeletes) {
|
||||
if (prefixToDelete.length() != BackupsDb.BACKUP_DIRECTORY_PATH_LENGTH
|
||||
&& prefixToDelete.length() != BackupsDb.MEDIA_DIRECTORY_PATH_LENGTH) {
|
||||
throw new IllegalArgumentException("Unexpected prefix deletion for " + prefixToDelete);
|
||||
}
|
||||
final String prefix = prefixToDelete + "/";
|
||||
return Mono
|
||||
.fromCompletionStage(this.remoteStorageManager.list(prefix, Optional.empty(), 1000))
|
||||
.expand(listResult -> {
|
||||
if (listResult.cursor().isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.fromCompletionStage(() -> this.remoteStorageManager.list(prefix, listResult.cursor(), 1000));
|
||||
})
|
||||
.flatMap(listResult -> Flux.fromIterable(listResult.objects()))
|
||||
.flatMap(
|
||||
result -> Mono.fromCompletionStage(() -> remoteStorageManager.delete(prefix + result.key())),
|
||||
concurrentDeletes)
|
||||
.count()
|
||||
.doOnSuccess(itemsRemoved -> DistributionSummary.builder(DELETE_COUNT_DISTRIBUTION_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(itemsRemoved))
|
||||
.then()
|
||||
.toFuture();
|
||||
}
|
||||
|
||||
interface PresentationSignatureVerifier {
|
||||
|
||||
Pair<BackupCredentialType, BackupLevel> verifySignature(byte[] signature, ECPublicKey publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the presentation was issued by us, which should be done before checking the stored public key
|
||||
*
|
||||
* @param presentation A ZK credential presentation that encodes the backupId and the receipt level of the requester
|
||||
* @return A function that can be used to verify a signature provided with the presentation
|
||||
*/
|
||||
private PresentationSignatureVerifier verifyPresentation(final BackupAuthCredentialPresentation presentation) {
|
||||
try {
|
||||
presentation.verify(clock.instant(), serverSecretParams);
|
||||
} catch (VerificationFailedException e) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "presentation_verification")
|
||||
.increment();
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("backup auth credential presentation verification failed")
|
||||
.withCause(e)
|
||||
.asRuntimeException();
|
||||
}
|
||||
return (signature, publicKey) -> {
|
||||
if (!publicKey.verifySignature(presentation.serialize(), signature)) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "signature_validation")
|
||||
.increment();
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("backup auth credential presentation signature verification failed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return new Pair<>(presentation.getType(), presentation.getBackupLevel());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the authenticated backup user is authorized to use the provided backupLevel
|
||||
*
|
||||
* @param backupUser The backup user to check
|
||||
* @param backupLevel The authorization level to verify the backupUser has access to
|
||||
* @throws {@link Status#PERMISSION_DENIED} error if the backup user is not authorized to access {@code backupLevel}
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
|
||||
if (backupUser.backupLevel().compareTo(backupLevel) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of(FAILURE_REASON_TAG_NAME, "level")))
|
||||
.increment();
|
||||
|
||||
throw Status.PERMISSION_DENIED
|
||||
.withDescription("credential does not support the requested operation")
|
||||
.asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the authenticated backup user is authenticated with the given credential type
|
||||
*
|
||||
* @param backupUser The backup user to check
|
||||
* @param credentialType The credential type to require
|
||||
* @throws {@link Status#UNAUTHENTICATED} error if the backup user is not authenticated with the given
|
||||
* {@code credentialType}
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static void checkBackupCredentialType(final AuthenticatedBackupUser backupUser, final BackupCredentialType credentialType) {
|
||||
if (backupUser.credentialType() != credentialType) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME,
|
||||
FAILURE_REASON_TAG_NAME, "credential_type")
|
||||
.increment();
|
||||
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("wrong credential type for the requested operation")
|
||||
.asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static String encodeMediaIdForCdn(final byte[] bytes) {
|
||||
return Base64.getUrlEncoder().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static byte[] decodeMediaIdFromCdn(final String base64) {
|
||||
return Base64.getUrlDecoder().decode(base64);
|
||||
}
|
||||
|
||||
private static String cdnMessageBackupName(final AuthenticatedBackupUser backupUser) {
|
||||
return "%s/%s".formatted(backupUser.backupDir(), MESSAGE_BACKUP_NAME);
|
||||
}
|
||||
|
||||
private static String cdnMediaDirectory(final String backupDir, final String mediaDir) {
|
||||
return "%s/%s/".formatted(backupDir, mediaDir);
|
||||
}
|
||||
|
||||
private static String cdnMediaDirectory(final AuthenticatedBackupUser backupUser) {
|
||||
return cdnMediaDirectory(backupUser.backupDir(), backupUser.mediaDir());
|
||||
}
|
||||
|
||||
private static String cdnMediaPath(final AuthenticatedBackupUser backupUser, final byte[] mediaId) {
|
||||
return "%s%s".formatted(cdnMediaDirectory(backupUser), encodeMediaIdForCdn(mediaId));
|
||||
}
|
||||
|
||||
static String rateLimitKey(final AuthenticatedBackupUser backupUser) {
|
||||
return Base64.getEncoder().encodeToString(BackupsDb.hashedBackupId(backupUser.backupId()));
|
||||
}
|
||||
|
||||
private static @Nullable UserAgent parseUserAgent(final String userAgentString) {
|
||||
try {
|
||||
return UserAgentUtil.parseUserAgentString(userAgentString);
|
||||
} catch (UnrecognizedUserAgentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record BackupUploadDescriptor(
|
||||
int cdn,
|
||||
String key,
|
||||
Map<String, String> headers,
|
||||
String signedUploadLocation) {}
|
||||
@@ -0,0 +1,833 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import io.grpc.Status;
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Predicate;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.Update;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
|
||||
/**
|
||||
* Tracks backup metadata in a persistent store.
|
||||
* <p>
|
||||
* It's assumed that the caller has already validated that the backupUser being operated on has valid credentials and
|
||||
* possesses the appropriate {@link BackupLevel} to perform the current operation.
|
||||
* <p>
|
||||
* Backup records track two timestamps indicating the last time that a user interacted with their backup. One for the
|
||||
* last refresh that contained a credential including media level, and the other for any access. After a period of
|
||||
* inactivity stale backups can be purged (either just the media, or the entire backup). Callers can discover what
|
||||
* backups are stale and whether only the media or the entire backup is stale via {@link #getExpiredBackups}.
|
||||
* <p>
|
||||
* Because backup objects reside on a transactionally unrelated store, expiring anything from the backup requires a 2
|
||||
* phase process. First the caller calls {@link #startExpiration} which will atomically update the user's backup
|
||||
* directories and record the cdn directory that should be expired. Then the caller must delete the expired directory,
|
||||
* calling {@link #finishExpiration} to clear the recorded expired prefix when complete. Since the user's backup
|
||||
* directories have been swapped, the deleter does not have to account for a user coming back and starting to upload
|
||||
* concurrently with the deletion.
|
||||
* <p>
|
||||
* If the directory deletion fails, a subsequent call to {@link #getExpiredBackups} will return the backup again
|
||||
* indicating that the old expired prefix needs to be cleaned up before any other expiration action is taken. For
|
||||
* example, if a media expiration fails and then in the next expiration pass the backup has become eligible for total
|
||||
* deletion, the caller still must process the stale media expiration first before processing the full deletion.
|
||||
*/
|
||||
public class BackupsDb {
|
||||
|
||||
private static final int DIR_NAME_LENGTH = generateDirName(new SecureRandom()).length();
|
||||
public static final int BACKUP_DIRECTORY_PATH_LENGTH = DIR_NAME_LENGTH;
|
||||
public static final int MEDIA_DIRECTORY_PATH_LENGTH = BACKUP_DIRECTORY_PATH_LENGTH + "/".length() + DIR_NAME_LENGTH;
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupsDb.class);
|
||||
static final int BACKUP_CDN = 3;
|
||||
|
||||
private final DynamoDbAsyncClient dynamoClient;
|
||||
private final String backupTableName;
|
||||
private final Clock clock;
|
||||
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
// The backups table
|
||||
|
||||
// B: 16 bytes that identifies the backup
|
||||
public static final String KEY_BACKUP_ID_HASH = "U";
|
||||
// N: Time in seconds since epoch of the last backup refresh. This timestamp must be periodically updated to avoid
|
||||
// garbage collection of archive objects.
|
||||
public static final String ATTR_LAST_REFRESH = "R";
|
||||
// N: Time in seconds since epoch of the last backup media refresh. This timestamp can only be updated if the client
|
||||
// has BackupLevel.PAID, and must be periodically updated to avoid garbage collection of media objects.
|
||||
public static final String ATTR_LAST_MEDIA_REFRESH = "MR";
|
||||
// B: A 32 byte public key that should be used to sign the presentation used to authenticate requests against the
|
||||
// backup-id
|
||||
public static final String ATTR_PUBLIC_KEY = "P";
|
||||
// N: Bytes consumed by this backup
|
||||
public static final String ATTR_MEDIA_BYTES_USED = "MB";
|
||||
// N: Number of media objects in the backup
|
||||
public static final String ATTR_MEDIA_COUNT = "MC";
|
||||
// N: The cdn number where the message backup is stored
|
||||
public static final String ATTR_CDN = "CDN";
|
||||
// N: Time in seconds since epoch of last backup media usage recalculation. This timestamp is updated whenever we
|
||||
// recalculate the up-to-date bytes used by querying the cdn(s) directly.
|
||||
public static final String ATTR_MEDIA_USAGE_LAST_RECALCULATION = "MBTS";
|
||||
// S: The name of the user's backup directory on the CDN
|
||||
public static final String ATTR_BACKUP_DIR = "BD";
|
||||
// S: The name of the user's media directory within the backup directory on the CDN
|
||||
public static final String ATTR_MEDIA_DIR = "MD";
|
||||
// S: A prefix pending deletion
|
||||
public static final String ATTR_EXPIRED_PREFIX = "EP";
|
||||
|
||||
public BackupsDb(
|
||||
final DynamoDbAsyncClient dynamoClient,
|
||||
final String backupTableName,
|
||||
final Clock clock) {
|
||||
this.dynamoClient = dynamoClient;
|
||||
this.backupTableName = backupTableName;
|
||||
this.clock = clock;
|
||||
this.secureRandom = new SecureRandom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the public key associated with a backupId.
|
||||
*
|
||||
* @param authenticatedBackupId The backup-id bytes that should be associated with the provided public key
|
||||
* @param authenticatedBackupLevel The backup level
|
||||
* @param publicKey The public key to associate with the backup id
|
||||
* @return A stage that completes when the public key has been set. If the backup-id already has a set public key that
|
||||
* does not match, the stage will be completed exceptionally with a {@link PublicKeyConflictException}
|
||||
*/
|
||||
CompletableFuture<Void> setPublicKey(
|
||||
final byte[] authenticatedBackupId,
|
||||
final BackupLevel authenticatedBackupLevel,
|
||||
final ECPublicKey publicKey) {
|
||||
final byte[] hashedBackupId = hashedBackupId(authenticatedBackupId);
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, authenticatedBackupLevel, hashedBackupId)
|
||||
.addSetExpression("#publicKey = :publicKey",
|
||||
Map.entry("#publicKey", ATTR_PUBLIC_KEY),
|
||||
Map.entry(":publicKey", AttributeValues.b(publicKey.serialize())))
|
||||
// When the user sets a public key, we ensure that they have a backupDir/mediaDir assigned
|
||||
.setDirectoryNamesIfMissing(secureRandom)
|
||||
.setRefreshTimes(clock)
|
||||
.withConditionExpression("attribute_not_exists(#publicKey) OR #publicKey = :publicKey")
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.exceptionally(ExceptionUtils.marshal(ConditionalCheckFailedException.class, e ->
|
||||
// There was already a row for this backup-id and it contained a different publicKey
|
||||
new PublicKeyConflictException()))
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data stored to authenticate a backup user
|
||||
*
|
||||
* @param publicKey The public key for the backup entry. All credentials for this backup user must be signed * by this
|
||||
* public key for the credential to be valid
|
||||
* @param backupDir The current backupDir for the backup user. If authentication is successful, the user may be given
|
||||
* credentials for this backupDir on the CDN
|
||||
* @param mediaDir The current mediaDir for the backup user. If authentication is successful, the user may be given *
|
||||
* credentials for the path backupDir/mediaDir on the CDN
|
||||
*/
|
||||
record AuthenticationData(ECPublicKey publicKey, String backupDir, String mediaDir) {}
|
||||
|
||||
CompletableFuture<Optional<AuthenticationData>> retrieveAuthenticationData(byte[] backupId) {
|
||||
final byte[] hashedBackupId = hashedBackupId(backupId);
|
||||
return dynamoClient.getItem(GetItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.consistentRead(true)
|
||||
.projectionExpression("#publicKey,#backupDir,#mediaDir")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#publicKey", ATTR_PUBLIC_KEY,
|
||||
"#backupDir", ATTR_BACKUP_DIR,
|
||||
"#mediaDir", ATTR_MEDIA_DIR))
|
||||
.build())
|
||||
.thenApply(response -> extractStoredPublicKey(response.item())
|
||||
.map(pubKey -> new AuthenticationData(
|
||||
pubKey,
|
||||
getDirName(response.item(), ATTR_BACKUP_DIR),
|
||||
getDirName(response.item(), ATTR_MEDIA_DIR))));
|
||||
}
|
||||
|
||||
private static String getDirName(final Map<String, AttributeValue> item, final String attr) {
|
||||
return AttributeValues.get(item, attr).map(AttributeValue::s).orElseThrow(() -> {
|
||||
logger.error("Backups with public keys should have directory names");
|
||||
return Status.INTERNAL
|
||||
.withDescription("Backups with public keys must have directory names")
|
||||
.asRuntimeException();
|
||||
});
|
||||
}
|
||||
|
||||
private static Optional<ECPublicKey> extractStoredPublicKey(final Map<String, AttributeValue> item) {
|
||||
return AttributeValues.get(item, ATTR_PUBLIC_KEY)
|
||||
.map(AttributeValue::b)
|
||||
.map(SdkBytes::asByteArray)
|
||||
.map(BackupsDb::deserializeStoredPublicKey);
|
||||
}
|
||||
|
||||
private static ECPublicKey deserializeStoredPublicKey(final byte[] publicKeyBytes) {
|
||||
try {
|
||||
return new ECPublicKey(publicKeyBytes);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.error("Invalid publicKey {}", HexFormat.of().formatHex(publicKeyBytes), e);
|
||||
throw Status.INTERNAL
|
||||
.withCause(e)
|
||||
.withDescription("Could not deserialize stored public key")
|
||||
.asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the quota in the backup table
|
||||
*
|
||||
* @param backupUser The backup user
|
||||
* @param mediaBytesDelta The length of the media after encryption. A negative length implies media being removed
|
||||
* @param mediaCountDelta The number of media objects being added, or if negative, removed
|
||||
* @return A stage that completes successfully once the table are updated.
|
||||
*/
|
||||
CompletableFuture<Void> trackMedia(final AuthenticatedBackupUser backupUser, final long mediaCountDelta,
|
||||
final long mediaBytesDelta) {
|
||||
return dynamoClient
|
||||
.updateItem(
|
||||
// Update the media quota and TTL
|
||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.incrementMediaBytes(mediaBytesDelta)
|
||||
.incrementMediaCount(mediaCountDelta)
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the last update timestamps for the backupId in the presentation
|
||||
*
|
||||
* @param backupUser an already authorized backup user
|
||||
*/
|
||||
CompletableFuture<StoredBackupAttributes> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
// update message backup TTL
|
||||
return dynamoClient.updateItem(UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(today)
|
||||
.updateItemBuilder()
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())
|
||||
.thenApply(updateItemResponse -> fromItem(updateItemResponse.attributes()));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Track that a backup will be stored for the user
|
||||
*
|
||||
* @param backupUser an already authorized backup user
|
||||
* @return A future that completes with the attributes of the backup before the update
|
||||
*/
|
||||
CompletableFuture<StoredBackupAttributes> addMessageBackup(final AuthenticatedBackupUser backupUser) {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
|
||||
return dynamoClient.updateItem(
|
||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(today)
|
||||
.setCdn(BACKUP_CDN)
|
||||
.updateItemBuilder()
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())
|
||||
.thenApply(updateItemResponse -> fromItem(updateItemResponse.attributes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that we couldn't schedule a deletion because one was already scheduled. The caller may want to delete the
|
||||
* objects directly.
|
||||
*/
|
||||
static class PendingDeletionException extends IOException {}
|
||||
|
||||
/**
|
||||
* Attempt to mark a backup as expired and swap in a new empty backupDir for the user.
|
||||
* <p>
|
||||
* After successful completion, the backupDir for the backup-id will be swapped to a new empty directory on the cdn,
|
||||
* and the row will be immediately marked eligible for expiration via {@link #getExpiredBackups}.
|
||||
* <p>
|
||||
* If there is already a pending deletion, this will not swap the backupDir. The expiration timestamps will be
|
||||
* updated, but the existing backupDir will remain. The caller should handle this case and start the deletion
|
||||
* immediately by catching {@link PendingDeletionException}.
|
||||
*
|
||||
* @param backupUser The backupUser whose data should be eventually deleted
|
||||
* @return A future that completes successfully if the user's data is now inaccessible, or with a
|
||||
* {@link PendingDeletionException} if the backupDir could not be changed.
|
||||
*/
|
||||
CompletableFuture<Void> scheduleBackupDeletion(final AuthenticatedBackupUser backupUser) {
|
||||
final byte[] hashedBackupId = hashedBackupId(backupUser);
|
||||
|
||||
// Clear usage metadata, swap names of things we intend to delete, and record our intent to delete in attr_expired_prefix
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.clearMediaUsage(clock)
|
||||
.expireDirectoryNames(secureRandom, ExpiredBackup.ExpirationType.ALL)
|
||||
.setRefreshTimes(Instant.ofEpochSecond(0))
|
||||
.addSetExpression("#expiredPrefix = :expiredPrefix",
|
||||
Map.entry("#expiredPrefix", ATTR_EXPIRED_PREFIX),
|
||||
Map.entry(":expiredPrefix", AttributeValues.s(backupUser.backupDir())))
|
||||
.withConditionExpression("attribute_not_exists(#expiredPrefix) OR #expiredPrefix = :expiredPrefix")
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.exceptionallyCompose(ExceptionUtils.exceptionallyHandler(ConditionalCheckFailedException.class, e ->
|
||||
// We already have a pending deletion for this backup-id. This is most likely to occur when the caller
|
||||
// is toggling backups on and off. In this case, it should be pretty cheap to directly delete the backup.
|
||||
// Instead of changing the backupDir, just make sure the row has expired/ timestamps and tell the caller we
|
||||
// couldn't schedule the deletion.
|
||||
dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.setRefreshTimes(Instant.ofEpochSecond(0))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.thenApply(ignore -> {
|
||||
throw ExceptionUtils.wrap(new PendingDeletionException());
|
||||
})))
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
record BackupDescription(int cdn, Optional<Long> mediaUsedSpace) {}
|
||||
|
||||
/**
|
||||
* Retrieve information about the backup
|
||||
*
|
||||
* @param backupUser an already authorized backup user
|
||||
* @return A {@link BackupDescription} containing the cdn of the message backup and the total number of media space
|
||||
* bytes used by the backup user.
|
||||
*/
|
||||
CompletableFuture<BackupDescription> describeBackup(final AuthenticatedBackupUser backupUser) {
|
||||
return dynamoClient.getItem(GetItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))
|
||||
.projectionExpression("#cdn,#mediaBytesUsed")
|
||||
.expressionAttributeNames(Map.of("#cdn", ATTR_CDN, "#mediaBytesUsed", ATTR_MEDIA_BYTES_USED))
|
||||
.consistentRead(true)
|
||||
.build())
|
||||
.thenApply(response -> {
|
||||
if (!response.hasItem()) {
|
||||
throw Status.NOT_FOUND.withDescription("Backup ID not found").asRuntimeException();
|
||||
}
|
||||
// If the client hasn't already uploaded a backup, return the cdn we would return if they did create one
|
||||
final int cdn = AttributeValues.getInt(response.item(), ATTR_CDN, BACKUP_CDN);
|
||||
final Optional<Long> mediaUsed = AttributeValues.get(response.item(), ATTR_MEDIA_BYTES_USED)
|
||||
.map(AttributeValue::n)
|
||||
.map(Long::parseLong);
|
||||
|
||||
return new BackupDescription(cdn, mediaUsed);
|
||||
});
|
||||
}
|
||||
|
||||
public record TimestampedUsageInfo(UsageInfo usageInfo, Instant lastRecalculationTime) {}
|
||||
|
||||
CompletableFuture<TimestampedUsageInfo> getMediaUsage(final AuthenticatedBackupUser backupUser) {
|
||||
return dynamoClient.getItem(GetItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))
|
||||
.projectionExpression("#mediaBytesUsed,#mediaCount,#usageRecalc")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#mediaBytesUsed", ATTR_MEDIA_BYTES_USED,
|
||||
"#mediaCount", ATTR_MEDIA_COUNT,
|
||||
"#usageRecalc", ATTR_MEDIA_USAGE_LAST_RECALCULATION))
|
||||
.consistentRead(true)
|
||||
.build())
|
||||
.thenApply(response -> {
|
||||
final long mediaUsed = AttributeValues.getLong(response.item(), ATTR_MEDIA_BYTES_USED, 0L);
|
||||
final long mediaCount = AttributeValues.getLong(response.item(), ATTR_MEDIA_COUNT, 0L);
|
||||
final long recalcSeconds = AttributeValues.getLong(response.item(), ATTR_MEDIA_USAGE_LAST_RECALCULATION, 0L);
|
||||
return new TimestampedUsageInfo(new UsageInfo(mediaUsed, mediaCount), Instant.ofEpochSecond(recalcSeconds));
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
CompletableFuture<Void> setMediaUsage(final AuthenticatedBackupUser backupUser, UsageInfo usageInfo) {
|
||||
return setMediaUsage(UpdateBuilder.forUser(backupTableName, backupUser), usageInfo);
|
||||
}
|
||||
|
||||
CompletableFuture<Void> setMediaUsage(final StoredBackupAttributes backupAttributes, UsageInfo usageInfo) {
|
||||
return setMediaUsage(new UpdateBuilder(backupTableName, BackupLevel.PAID, backupAttributes.hashedBackupId()), usageInfo);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> setMediaUsage(final UpdateBuilder updateBuilder, UsageInfo usageInfo) {
|
||||
return dynamoClient.updateItem(
|
||||
updateBuilder
|
||||
.addSetExpression("#mediaBytesUsed = :mediaBytesUsed",
|
||||
Map.entry("#mediaBytesUsed", ATTR_MEDIA_BYTES_USED),
|
||||
Map.entry(":mediaBytesUsed", AttributeValues.n(usageInfo.bytesUsed())))
|
||||
.addSetExpression("#mediaCount = :mediaCount",
|
||||
Map.entry("#mediaCount", ATTR_MEDIA_COUNT),
|
||||
Map.entry(":mediaCount", AttributeValues.n(usageInfo.numObjects())))
|
||||
.addSetExpression("#mediaRecalc = :mediaRecalc",
|
||||
Map.entry("#mediaRecalc", ATTR_MEDIA_USAGE_LAST_RECALCULATION),
|
||||
Map.entry(":mediaRecalc", AttributeValues.n(clock.instant().getEpochSecond())))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks the backup as undergoing expiration.
|
||||
* <p>
|
||||
* This must be called before beginning to delete items in the CDN with the prefix specified by
|
||||
* {@link ExpiredBackup#prefixToDelete()}. If the prefix has been successfully deleted, {@link #finishExpiration} must
|
||||
* be called.
|
||||
*
|
||||
* @param expiredBackup The backup to expire
|
||||
* @return A stage that completes when the backup has been marked for expiration
|
||||
*/
|
||||
CompletableFuture<Void> startExpiration(final ExpiredBackup expiredBackup) {
|
||||
if (expiredBackup.expirationType() == ExpiredBackup.ExpirationType.GARBAGE_COLLECTION) {
|
||||
// We've already updated the row on a prior (failed) attempt, just need to remove the data from the cdn now
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
// Clear usage metadata, swap names of things we intend to delete, and record our intent to delete in attr_expired_prefix
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, expiredBackup.hashedBackupId())
|
||||
.clearMediaUsage(clock)
|
||||
.expireDirectoryNames(secureRandom, expiredBackup.expirationType())
|
||||
.addRemoveExpression(Map.entry("#mediaRefresh", ATTR_LAST_MEDIA_REFRESH))
|
||||
.addSetExpression("#expiredPrefix = :expiredPrefix",
|
||||
Map.entry("#expiredPrefix", ATTR_EXPIRED_PREFIX),
|
||||
Map.entry(":expiredPrefix", AttributeValues.s(expiredBackup.prefixToDelete())))
|
||||
.withConditionExpression("attribute_not_exists(#expiredPrefix) OR #expiredPrefix = :expiredPrefix")
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete expiration of a backup started with {@link #startExpiration}
|
||||
* <p>
|
||||
* If the expiration was for the entire backup, this will delete the entire item for the backup.
|
||||
*
|
||||
* @param expiredBackup The backup to expire
|
||||
* @return A stage that completes when the expiration is marked as finished
|
||||
*/
|
||||
CompletableFuture<Void> finishExpiration(final ExpiredBackup expiredBackup) {
|
||||
final byte[] hashedBackupId = expiredBackup.hashedBackupId();
|
||||
if (expiredBackup.expirationType() == ExpiredBackup.ExpirationType.ALL) {
|
||||
final long expectedLastRefresh = expiredBackup.lastRefresh().getEpochSecond();
|
||||
return dynamoClient.deleteItem(DeleteItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.conditionExpression("#lastRefresh <= :expectedLastRefresh")
|
||||
.expressionAttributeNames(Map.of("#lastRefresh", ATTR_LAST_REFRESH))
|
||||
.expressionAttributeValues(Map.of(":expectedLastRefresh", AttributeValues.n(expectedLastRefresh)))
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
} else {
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.addRemoveExpression(Map.entry("#expiredPrefixes", ATTR_EXPIRED_PREFIX))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
}
|
||||
|
||||
Flux<StoredBackupAttributes> listBackupAttributes(final int segments) {
|
||||
if (segments < 1) {
|
||||
throw new IllegalArgumentException("Total number of segments must be positive");
|
||||
}
|
||||
|
||||
return Flux.range(0, segments)
|
||||
.flatMap(segment -> dynamoClient.scanPaginator(ScanRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.consistentRead(true)
|
||||
.segment(segment)
|
||||
.totalSegments(segments)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#backupIdHash", KEY_BACKUP_ID_HASH,
|
||||
"#refresh", ATTR_LAST_REFRESH,
|
||||
"#mediaRefresh", ATTR_LAST_MEDIA_REFRESH,
|
||||
"#bytesUsed", ATTR_MEDIA_BYTES_USED,
|
||||
"#numObjects", ATTR_MEDIA_COUNT,
|
||||
"#backupDir", ATTR_BACKUP_DIR,
|
||||
"#mediaDir", ATTR_MEDIA_DIR))
|
||||
.projectionExpression("#backupIdHash, #refresh, #mediaRefresh, #bytesUsed, #numObjects, #backupDir, #mediaDir")
|
||||
.build()))
|
||||
// Don't use the SDK's item publisher, works around https://github.com/aws/aws-sdk-java-v2/issues/6411
|
||||
.concatMap(page -> Flux.fromIterable(page.items()))
|
||||
.filter(item -> item.containsKey(KEY_BACKUP_ID_HASH))
|
||||
.map(BackupsDb::fromItem);
|
||||
}
|
||||
|
||||
private static StoredBackupAttributes fromItem(Map<String, AttributeValue> item) {
|
||||
return new StoredBackupAttributes(
|
||||
AttributeValues.getByteArray(item, KEY_BACKUP_ID_HASH, null),
|
||||
AttributeValues.getString(item, ATTR_BACKUP_DIR, null),
|
||||
AttributeValues.getString(item, ATTR_MEDIA_DIR, null),
|
||||
Instant.ofEpochSecond(AttributeValues.getLong(item, ATTR_LAST_REFRESH, 0L)),
|
||||
Instant.ofEpochSecond(AttributeValues.getLong(item, ATTR_LAST_MEDIA_REFRESH, 0L)),
|
||||
AttributeValues.getLong(item, ATTR_MEDIA_BYTES_USED, 0L),
|
||||
AttributeValues.getLong(item, ATTR_MEDIA_COUNT, 0L));
|
||||
}
|
||||
|
||||
Flux<ExpiredBackup> getExpiredBackups(final int segments, final Scheduler scheduler, final Instant purgeTime) {
|
||||
if (segments < 1) {
|
||||
throw new IllegalArgumentException("Total number of segments must be positive");
|
||||
}
|
||||
|
||||
return Flux.range(0, segments)
|
||||
.parallel()
|
||||
.runOn(scheduler)
|
||||
.flatMap(segment -> dynamoClient.scanPaginator(ScanRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.consistentRead(true)
|
||||
.segment(segment)
|
||||
.totalSegments(segments)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#backupIdHash", KEY_BACKUP_ID_HASH,
|
||||
"#refresh", ATTR_LAST_REFRESH,
|
||||
"#mediaRefresh", ATTR_LAST_MEDIA_REFRESH,
|
||||
"#backupDir", ATTR_BACKUP_DIR,
|
||||
"#mediaDir", ATTR_MEDIA_DIR,
|
||||
"#expiredPrefix", ATTR_EXPIRED_PREFIX))
|
||||
.expressionAttributeValues(Map.of(":purgeTime", AttributeValues.n(purgeTime.getEpochSecond())))
|
||||
.projectionExpression("#backupIdHash, #refresh, #mediaRefresh, #backupDir, #mediaDir, #expiredPrefix")
|
||||
.filterExpression(
|
||||
"(#refresh < :purgeTime) OR (#mediaRefresh < :purgeTime) OR attribute_exists(#expiredPrefix)")
|
||||
.build())
|
||||
.items())
|
||||
.sequential()
|
||||
.filter(Predicate.not(Map::isEmpty))
|
||||
.mapNotNull(item -> {
|
||||
final byte[] hashedBackupId = AttributeValues.getByteArray(item, KEY_BACKUP_ID_HASH, null);
|
||||
if (hashedBackupId == null) {
|
||||
return null;
|
||||
}
|
||||
final String backupDir = AttributeValues.getString(item, ATTR_BACKUP_DIR, null);
|
||||
final String mediaDir = AttributeValues.getString(item, ATTR_MEDIA_DIR, null);
|
||||
if (backupDir == null || mediaDir == null) {
|
||||
// Could be the case for backups that have not yet set a public key
|
||||
return null;
|
||||
}
|
||||
final long lastRefresh = AttributeValues.getLong(item, ATTR_LAST_REFRESH, Long.MAX_VALUE);
|
||||
final long lastMediaRefresh = AttributeValues.getLong(item, ATTR_LAST_MEDIA_REFRESH, Long.MAX_VALUE);
|
||||
final String existingExpiration = AttributeValues.getString(item, ATTR_EXPIRED_PREFIX, null);
|
||||
|
||||
final ExpiredBackup expiredBackup;
|
||||
if (existingExpiration != null) {
|
||||
// If we have work from a failed previous expiration, handle that before worrying about any new expirations.
|
||||
// This guarantees we won't accumulate expirations
|
||||
expiredBackup = new ExpiredBackup(hashedBackupId, ExpiredBackup.ExpirationType.GARBAGE_COLLECTION,
|
||||
Instant.ofEpochSecond(lastRefresh), existingExpiration);
|
||||
} else if (lastRefresh < purgeTime.getEpochSecond()) {
|
||||
// The whole backup was expired
|
||||
expiredBackup = new ExpiredBackup(hashedBackupId, ExpiredBackup.ExpirationType.ALL,
|
||||
Instant.ofEpochSecond(lastRefresh), backupDir);
|
||||
} else if (lastMediaRefresh < purgeTime.getEpochSecond()) {
|
||||
// The media was expired
|
||||
expiredBackup = new ExpiredBackup(hashedBackupId, ExpiredBackup.ExpirationType.MEDIA,
|
||||
Instant.ofEpochSecond(lastRefresh), backupDir + "/" + mediaDir);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValid(expiredBackup)) {
|
||||
logger.error("Not expiring backup {} for backupId {} with invalid cdn path prefixes",
|
||||
HexFormat.of().formatHex(expiredBackup.hashedBackupId()),
|
||||
expiredBackup);
|
||||
return null;
|
||||
}
|
||||
return expiredBackup;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup expiration will expire any prefix we tell it to, so confirm that the directory names that came out of the
|
||||
* database have the correct shape before handing them off.
|
||||
*
|
||||
* @param expiredBackup The ExpiredBackup object to check
|
||||
* @return Whether this is a valid expiration object
|
||||
*/
|
||||
private static boolean isValid(final ExpiredBackup expiredBackup) {
|
||||
// expired prefixes should be of the form "backupDir" or "backupDir/mediaDir"
|
||||
return switch (expiredBackup.expirationType()) {
|
||||
case MEDIA -> expiredBackup.prefixToDelete().length() == MEDIA_DIRECTORY_PATH_LENGTH;
|
||||
case ALL -> expiredBackup.prefixToDelete().length() == BACKUP_DIRECTORY_PATH_LENGTH;
|
||||
case GARBAGE_COLLECTION -> expiredBackup.prefixToDelete().length() == MEDIA_DIRECTORY_PATH_LENGTH ||
|
||||
expiredBackup.prefixToDelete().length() == BACKUP_DIRECTORY_PATH_LENGTH;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ddb update statements for the backups table
|
||||
*/
|
||||
private static class UpdateBuilder {
|
||||
|
||||
private final List<String> setStatements = new ArrayList<>();
|
||||
private final List<String> removeStatements = new ArrayList<>();
|
||||
private final Map<String, AttributeValue> attrValues = new HashMap<>();
|
||||
private final Map<String, String> attrNames = new HashMap<>();
|
||||
|
||||
private final String tableName;
|
||||
private final BackupLevel backupLevel;
|
||||
private final byte[] hashedBackupId;
|
||||
private String conditionExpression = null;
|
||||
|
||||
static UpdateBuilder forUser(String tableName, AuthenticatedBackupUser backupUser) {
|
||||
return new UpdateBuilder(tableName, backupUser.backupLevel(), hashedBackupId(backupUser));
|
||||
}
|
||||
|
||||
UpdateBuilder(String tableName, BackupLevel backupLevel, byte[] hashedBackupId) {
|
||||
this.tableName = tableName;
|
||||
this.backupLevel = backupLevel;
|
||||
this.hashedBackupId = hashedBackupId;
|
||||
}
|
||||
|
||||
private void addAttrValue(Map.Entry<String, AttributeValue> attrValue) {
|
||||
final AttributeValue old = attrValues.put(attrValue.getKey(), attrValue.getValue());
|
||||
if (old != null && !old.equals(attrValue.getValue())) {
|
||||
throw new IllegalArgumentException("duplicate attrValue key used for different values");
|
||||
}
|
||||
}
|
||||
|
||||
private void addAttrName(Map.Entry<String, String> attrName) {
|
||||
final String oldName = attrNames.put(attrName.getKey(), attrName.getValue());
|
||||
if (oldName != null && !oldName.equals(attrName.getValue())) {
|
||||
throw new IllegalArgumentException("duplicate attrName key used for different attribute names");
|
||||
}
|
||||
}
|
||||
|
||||
private void addAttrs(final Map.Entry<String, String> attrName, final Map.Entry<String, AttributeValue> attrValue) {
|
||||
addAttrName(attrName);
|
||||
addAttrValue(attrValue);
|
||||
}
|
||||
|
||||
UpdateBuilder addSetExpression(
|
||||
final String update,
|
||||
final Map.Entry<String, String> attrName,
|
||||
final Map.Entry<String, AttributeValue> attrValue) {
|
||||
setStatements.add(update);
|
||||
addAttrs(attrName, attrValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder addSetExpression(final String update) {
|
||||
setStatements.add(update);
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder addRemoveExpression(final Map.Entry<String, String> attrName) {
|
||||
addAttrName(attrName);
|
||||
removeStatements.add(attrName.getKey());
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder withConditionExpression(final String conditionExpression) {
|
||||
this.conditionExpression = conditionExpression;
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder withConditionExpression(
|
||||
final String conditionExpression,
|
||||
final Map.Entry<String, String> attrName,
|
||||
final Map.Entry<String, AttributeValue> attrValue) {
|
||||
this.addAttrs(attrName, attrValue);
|
||||
this.conditionExpression = conditionExpression;
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder setCdn(final int cdn) {
|
||||
return addSetExpression(
|
||||
"#cdn = :cdn",
|
||||
Map.entry("#cdn", ATTR_CDN),
|
||||
Map.entry(":cdn", AttributeValues.n(cdn)));
|
||||
}
|
||||
|
||||
UpdateBuilder incrementMediaCount(long delta) {
|
||||
addAttrName(Map.entry("#mediaCount", ATTR_MEDIA_COUNT));
|
||||
addAttrValue(Map.entry(":zero", AttributeValues.n(0)));
|
||||
addAttrValue(Map.entry(":mediaCountDelta", AttributeValues.n(delta)));
|
||||
addSetExpression("#mediaCount = if_not_exists(#mediaCount, :zero) + :mediaCountDelta");
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder incrementMediaBytes(long delta) {
|
||||
addAttrName(Map.entry("#mediaBytes", ATTR_MEDIA_BYTES_USED));
|
||||
addAttrValue(Map.entry(":zero", AttributeValues.n(0)));
|
||||
addAttrValue(Map.entry(":mediaBytesDelta", AttributeValues.n(delta)));
|
||||
addSetExpression("#mediaBytes = if_not_exists(#mediaBytes, :zero) + :mediaBytesDelta");
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder clearMediaUsage(final Clock clock) {
|
||||
addSetExpression("#mediaBytesUsed = :mediaBytesUsed",
|
||||
Map.entry("#mediaBytesUsed", ATTR_MEDIA_BYTES_USED),
|
||||
Map.entry(":mediaBytesUsed", AttributeValues.n(0L)));
|
||||
addSetExpression("#mediaCount = :mediaCount",
|
||||
Map.entry("#mediaCount", ATTR_MEDIA_COUNT),
|
||||
Map.entry(":mediaCount", AttributeValues.n(0L)));
|
||||
addSetExpression("#mediaRecalc = :mediaRecalc",
|
||||
Map.entry("#mediaRecalc", ATTR_MEDIA_USAGE_LAST_RECALCULATION),
|
||||
Map.entry(":mediaRecalc", AttributeValues.n(clock.instant().getEpochSecond())));
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder setDirectoryNamesIfMissing(final SecureRandom secureRandom) {
|
||||
final String backupDir = generateDirName(secureRandom);
|
||||
final String mediaDir = generateDirName(secureRandom);
|
||||
addSetExpression("#backupDir = if_not_exists(#backupDir, :backupDir)",
|
||||
Map.entry("#backupDir", ATTR_BACKUP_DIR),
|
||||
Map.entry(":backupDir", AttributeValues.s(backupDir)));
|
||||
|
||||
addSetExpression("#mediaDir = if_not_exists(#mediaDir, :mediaDir)",
|
||||
Map.entry("#mediaDir", ATTR_MEDIA_DIR),
|
||||
Map.entry(":mediaDir", AttributeValues.s(mediaDir)));
|
||||
return this;
|
||||
}
|
||||
|
||||
UpdateBuilder expireDirectoryNames(
|
||||
final SecureRandom secureRandom,
|
||||
final ExpiredBackup.ExpirationType expirationType) {
|
||||
final String backupDir = generateDirName(secureRandom);
|
||||
final String mediaDir = generateDirName(secureRandom);
|
||||
return switch (expirationType) {
|
||||
case GARBAGE_COLLECTION -> this;
|
||||
case MEDIA -> this.addSetExpression("#mediaDir = :mediaDir",
|
||||
Map.entry("#mediaDir", ATTR_MEDIA_DIR),
|
||||
Map.entry(":mediaDir", AttributeValues.s(mediaDir)));
|
||||
case ALL -> this
|
||||
.addSetExpression("#mediaDir = :mediaDir",
|
||||
Map.entry("#mediaDir", ATTR_MEDIA_DIR),
|
||||
Map.entry(":mediaDir", AttributeValues.s(mediaDir)))
|
||||
.addSetExpression("#backupDir = :backupDir",
|
||||
Map.entry("#backupDir", ATTR_BACKUP_DIR),
|
||||
Map.entry(":backupDir", AttributeValues.s(backupDir)));
|
||||
};
|
||||
}
|
||||
|
||||
UpdateBuilder setRefreshTimes(final Clock clock) {
|
||||
return setRefreshTimes(clock.instant().truncatedTo(ChronoUnit.DAYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the lastRefresh time as part of the update
|
||||
* <p>
|
||||
* This always updates lastRefreshTime, and updates lastMediaRefreshTime if the backup user has the appropriate
|
||||
* level.
|
||||
*/
|
||||
UpdateBuilder setRefreshTimes(final Instant refreshTime) {
|
||||
if (!refreshTime.truncatedTo(ChronoUnit.DAYS).equals(refreshTime)) {
|
||||
throw new IllegalArgumentException("Refresh time must be day aligned");
|
||||
}
|
||||
addSetExpression("#lastRefreshTime = :lastRefreshTime",
|
||||
Map.entry("#lastRefreshTime", ATTR_LAST_REFRESH),
|
||||
Map.entry(":lastRefreshTime", AttributeValues.n(refreshTime.getEpochSecond())));
|
||||
|
||||
if (backupLevel.compareTo(BackupLevel.PAID) >= 0) {
|
||||
// update the media time if we have the appropriate level
|
||||
addSetExpression("#lastMediaRefreshTime = :lastMediaRefreshTime",
|
||||
Map.entry("#lastMediaRefreshTime", ATTR_LAST_MEDIA_REFRESH),
|
||||
Map.entry(":lastMediaRefreshTime", AttributeValues.n(refreshTime.getEpochSecond())));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private String updateExpression() {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
if (!setStatements.isEmpty()) {
|
||||
sb.append("SET ");
|
||||
sb.append(String.join(",", setStatements));
|
||||
}
|
||||
if (!removeStatements.isEmpty()) {
|
||||
sb.append(" REMOVE ");
|
||||
sb.append(String.join(",", removeStatements));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a non-transactional update
|
||||
*
|
||||
* @return An {@link UpdateItemRequest#builder()} that can be used with updateItem
|
||||
*/
|
||||
UpdateItemRequest.Builder updateItemBuilder() {
|
||||
final UpdateItemRequest.Builder bldr = UpdateItemRequest.builder()
|
||||
.tableName(tableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.updateExpression(updateExpression())
|
||||
.expressionAttributeNames(attrNames);
|
||||
if (!this.attrValues.isEmpty()) {
|
||||
bldr.expressionAttributeValues(attrValues);
|
||||
}
|
||||
if (this.conditionExpression != null) {
|
||||
bldr.conditionExpression(conditionExpression);
|
||||
}
|
||||
return bldr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a transactional update
|
||||
*
|
||||
* @return An {@link Update#builder()} that can be used with transactItem
|
||||
*/
|
||||
Update.Builder transactItemBuilder() {
|
||||
final Update.Builder bldr = Update.builder()
|
||||
.tableName(tableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.updateExpression(updateExpression())
|
||||
.expressionAttributeNames(attrNames)
|
||||
.expressionAttributeValues(attrValues);
|
||||
if (this.conditionExpression != null) {
|
||||
bldr.conditionExpression(conditionExpression);
|
||||
}
|
||||
return bldr;
|
||||
}
|
||||
}
|
||||
|
||||
static String generateDirName(final SecureRandom secureRandom) {
|
||||
final byte[] bytes = new byte[16];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static byte[] hashedBackupId(final AuthenticatedBackupUser backupId) {
|
||||
return hashedBackupId(backupId.backupId());
|
||||
}
|
||||
|
||||
static byte[] hashedBackupId(final byte[] backupId) {
|
||||
try {
|
||||
return Arrays.copyOf(MessageDigest.getInstance("SHA-256").digest(backupId), 16);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import org.apache.http.HttpHeaders;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
public class Cdn3BackupCredentialGenerator {
|
||||
|
||||
public static final String CDN_PATH = "backups";
|
||||
public static final int BACKUP_CDN = 3;
|
||||
|
||||
private static String READ_PERMISSION = "read";
|
||||
private static String WRITE_PERMISSION = "write";
|
||||
private static String PERMISSION_SEPARATOR = "$";
|
||||
|
||||
// Write entities will be of the form 'write$backups/<string>
|
||||
private static final String WRITE_ENTITY_PREFIX = String.format("%s%s%s/", WRITE_PERMISSION, PERMISSION_SEPARATOR,
|
||||
CDN_PATH);
|
||||
// Read entities will be of the form 'read$backups/<string>
|
||||
private static final String READ_ENTITY_PREFIX = String.format("%s%s%s/", READ_PERMISSION, PERMISSION_SEPARATOR,
|
||||
CDN_PATH);
|
||||
|
||||
private final ExternalServiceCredentialsGenerator credentialsGenerator;
|
||||
private final String tusUri;
|
||||
|
||||
public Cdn3BackupCredentialGenerator(final TusConfiguration cfg) {
|
||||
this.tusUri = cfg.uploadUri();
|
||||
this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg);
|
||||
}
|
||||
|
||||
private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock,
|
||||
final TusConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.prependUsername(false)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
}
|
||||
|
||||
public BackupUploadDescriptor generateUpload(final String key) {
|
||||
if (key.isBlank()) {
|
||||
throw new IllegalArgumentException("Upload descriptors must have non-empty keys");
|
||||
}
|
||||
final String entity = WRITE_ENTITY_PREFIX + key;
|
||||
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);
|
||||
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
||||
final Map<String, String> headers = Map.of(
|
||||
HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials),
|
||||
"Upload-Metadata", String.format("filename %s", b64Key));
|
||||
|
||||
return new BackupUploadDescriptor(
|
||||
BACKUP_CDN,
|
||||
key,
|
||||
headers,
|
||||
tusUri + "/" + CDN_PATH);
|
||||
}
|
||||
|
||||
public Map<String, String> readHeaders(final String hashedBackupId) {
|
||||
if (hashedBackupId.isBlank()) {
|
||||
throw new IllegalArgumentException("Backup subdir name must be non-empty");
|
||||
}
|
||||
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(
|
||||
READ_ENTITY_PREFIX + hashedBackupId);
|
||||
return Map.of(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HttpUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(Cdn3RemoteStorageManager.class);
|
||||
|
||||
private final FaultTolerantHttpClient storageManagerHttpClient;
|
||||
private final String storageManagerBaseUrl;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final Map<Integer, String> sourceSchemes;
|
||||
|
||||
static final String CLIENT_ID_HEADER = "CF-Access-Client-Id";
|
||||
static final String CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
|
||||
|
||||
private static final String STORAGE_MANAGER_STATUS_COUNTER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class,
|
||||
"storageManagerStatus");
|
||||
|
||||
private static final String STORAGE_MANAGER_TIMER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class,
|
||||
"storageManager");
|
||||
private static final String OPERATION_TAG_NAME = "op";
|
||||
private static final String STATUS_TAG_NAME = "status";
|
||||
|
||||
private static final String OBJECT_REMOVED_ON_DELETE_COUNTER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class, "objectRemovedOnDelete");
|
||||
|
||||
public Cdn3RemoteStorageManager(
|
||||
final ExecutorService httpExecutor,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final Cdn3StorageManagerConfiguration configuration) {
|
||||
|
||||
// strip trailing "/" for easier URI construction
|
||||
this.storageManagerBaseUrl = StringUtils.removeEnd(configuration.baseUri(), "/");
|
||||
this.clientId = configuration.clientId();
|
||||
this.clientSecret = configuration.clientSecret().value();
|
||||
|
||||
// Client used for calls to storage-manager
|
||||
this.storageManagerHttpClient = FaultTolerantHttpClient.newBuilder("cdn3-storage-manager", httpExecutor)
|
||||
.withCircuitBreaker(configuration.circuitBreakerConfigurationName())
|
||||
.withRetry(configuration.retryConfigurationName(), retryExecutor)
|
||||
.withConnectTimeout(Duration.ofSeconds(10))
|
||||
.withVersion(HttpClient.Version.HTTP_2)
|
||||
.withNumClients(configuration.numHttpClients())
|
||||
.build();
|
||||
this.sourceSchemes = configuration.sourceSchemes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int cdnNumber() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<Void> copy(
|
||||
final int sourceCdn,
|
||||
final String sourceKey,
|
||||
final int expectedSourceLength,
|
||||
final MediaEncryptionParameters encryptionParameters,
|
||||
final String destinationKey) {
|
||||
final String sourceScheme = this.sourceSchemes.get(sourceCdn);
|
||||
if (sourceScheme == null) {
|
||||
return CompletableFuture.failedFuture(
|
||||
new SourceObjectNotFoundException("Cdn3RemoteStorageManager cannot copy from " + sourceCdn));
|
||||
}
|
||||
final String requestBody = new Cdn3CopyRequest(
|
||||
encryptionParameters,
|
||||
new Cdn3CopyRequest.SourceDescriptor(sourceScheme, sourceKey),
|
||||
expectedSourceLength,
|
||||
destinationKey).json();
|
||||
|
||||
final Timer.Sample sample = Timer.start();
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||
.uri(URI.create(copyUrl()))
|
||||
.header("Content-Type", "application/json")
|
||||
.header(CLIENT_ID_HEADER, clientId)
|
||||
.header(CLIENT_SECRET_HEADER, clientSecret)
|
||||
.build();
|
||||
return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenAccept(response -> {
|
||||
Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,
|
||||
OPERATION_TAG_NAME, "copy",
|
||||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
if (response.statusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
|
||||
throw ExceptionUtils.wrap(new SourceObjectNotFoundException());
|
||||
} else if (response.statusCode() == Response.Status.CONFLICT.getStatusCode()) {
|
||||
throw ExceptionUtils.wrap(new InvalidLengthException(response.body()));
|
||||
} else if (!HttpUtils.isSuccessfulResponse(response.statusCode())) {
|
||||
logger.info("Failed to copy via storage-manager {} {}", response.statusCode(), response.body());
|
||||
throw ExceptionUtils.wrap(new IOException("Failed to copy object: " + response.statusCode()));
|
||||
}
|
||||
})
|
||||
.whenComplete((ignored, ignoredException) ->
|
||||
sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, "copy")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized copy request for cdn3 storage manager
|
||||
*/
|
||||
record Cdn3CopyRequest(
|
||||
String encryptionKey, String hmacKey,
|
||||
SourceDescriptor source, int expectedSourceLength,
|
||||
String dst) {
|
||||
|
||||
Cdn3CopyRequest(MediaEncryptionParameters parameters, SourceDescriptor source, int expectedSourceLength,
|
||||
String dst) {
|
||||
this(Base64.getEncoder().encodeToString(parameters.aesEncryptionKey().getEncoded()),
|
||||
Base64.getEncoder().encodeToString(parameters.hmacSHA256Key().getEncoded()),
|
||||
source, expectedSourceLength, dst);
|
||||
}
|
||||
|
||||
record SourceDescriptor(String scheme, String key) {}
|
||||
|
||||
String json() {
|
||||
try {
|
||||
return SystemMapper.jsonMapper().writeValueAsString(this);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Could not serialize copy request", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<ListResult> list(
|
||||
final String prefix,
|
||||
final Optional<String> cursor,
|
||||
final long limit) {
|
||||
final Timer.Sample sample = Timer.start();
|
||||
|
||||
final Map<String, String> queryParams = new HashMap<>();
|
||||
queryParams.put("prefix", prefix);
|
||||
queryParams.put("limit", Long.toString(limit));
|
||||
cursor.ifPresent(s -> queryParams.put("cursor", cursor.get()));
|
||||
|
||||
final HttpRequest request = HttpRequest.newBuilder().GET()
|
||||
.uri(URI.create("%s%s".formatted(listUrl(), HttpUtils.queryParamString(queryParams.entrySet()))))
|
||||
.header(CLIENT_ID_HEADER, clientId)
|
||||
.header(CLIENT_SECRET_HEADER, clientSecret)
|
||||
.build();
|
||||
|
||||
return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApply(response -> {
|
||||
Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,
|
||||
OPERATION_TAG_NAME, "list",
|
||||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
try {
|
||||
return parseListResponse(response, prefix);
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.wrap(e);
|
||||
}
|
||||
})
|
||||
.whenComplete((ignored, ignoredException) ->
|
||||
sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, "list")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized list response from storage manager
|
||||
*/
|
||||
record Cdn3ListResponse(@NotNull List<Entry> objects, @Nullable String cursor) {
|
||||
|
||||
record Entry(@NotNull String key, @NotNull long size) {}
|
||||
}
|
||||
|
||||
private static ListResult parseListResponse(final HttpResponse<InputStream> httpListResponse, final String prefix)
|
||||
throws IOException {
|
||||
if (!HttpUtils.isSuccessfulResponse(httpListResponse.statusCode())) {
|
||||
throw new IOException("Failed to list objects: " + httpListResponse.statusCode());
|
||||
}
|
||||
final Cdn3ListResponse result = SystemMapper.jsonMapper()
|
||||
.readValue(httpListResponse.body(), Cdn3ListResponse.class);
|
||||
|
||||
final List<ListResult.Entry> objects = new ArrayList<>(result.objects.size());
|
||||
for (Cdn3ListResponse.Entry entry : result.objects) {
|
||||
if (!entry.key().startsWith(prefix)) {
|
||||
logger.error("unexpected listing result from cdn3 - entry {} does not contain requested prefix {}",
|
||||
entry.key(), prefix);
|
||||
throw new IOException("prefix listing returned unexpected result");
|
||||
}
|
||||
objects.add(new ListResult.Entry(entry.key().substring(prefix.length()), entry.size()));
|
||||
}
|
||||
return new ListResult(objects, Optional.ofNullable(result.cursor));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serialized usage response from storage manager
|
||||
*/
|
||||
record UsageResponse(@NotNull long numObjects, @NotNull long bytesUsed) {}
|
||||
|
||||
|
||||
@Override
|
||||
public CompletionStage<UsageInfo> calculateBytesUsed(final String prefix) {
|
||||
final Timer.Sample sample = Timer.start();
|
||||
final HttpRequest request = HttpRequest.newBuilder().GET()
|
||||
.uri(URI.create("%s%s".formatted(
|
||||
usageUrl(),
|
||||
HttpUtils.queryParamString(Map.of("prefix", prefix).entrySet()))))
|
||||
.header(CLIENT_ID_HEADER, clientId)
|
||||
.header(CLIENT_SECRET_HEADER, clientSecret)
|
||||
.build();
|
||||
return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApply(response -> {
|
||||
Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,
|
||||
OPERATION_TAG_NAME, "usage",
|
||||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
try {
|
||||
return parseUsageResponse(response);
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.wrap(e);
|
||||
}
|
||||
})
|
||||
.whenComplete((ignored, ignoredException) ->
|
||||
sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, "usage")));
|
||||
}
|
||||
|
||||
private static UsageInfo parseUsageResponse(final HttpResponse<InputStream> httpUsageResponse) throws IOException {
|
||||
if (!HttpUtils.isSuccessfulResponse(httpUsageResponse.statusCode())) {
|
||||
throw new IOException("Failed to retrieve usage: " + httpUsageResponse.statusCode());
|
||||
}
|
||||
final UsageResponse response = SystemMapper.jsonMapper().readValue(httpUsageResponse.body(), UsageResponse.class);
|
||||
return new UsageInfo(response.bytesUsed(), response.numObjects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized delete response from storage manager
|
||||
*/
|
||||
record DeleteResponse(@NotNull long bytesDeleted) {}
|
||||
|
||||
public CompletionStage<Long> delete(final String key) {
|
||||
final Timer.Sample sample = Timer.start();
|
||||
final HttpRequest request = HttpRequest.newBuilder().DELETE()
|
||||
.uri(URI.create(deleteUrl(key)))
|
||||
.header(CLIENT_ID_HEADER, clientId)
|
||||
.header(CLIENT_SECRET_HEADER, clientSecret)
|
||||
.build();
|
||||
return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApply(response -> {
|
||||
Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,
|
||||
OPERATION_TAG_NAME, "delete",
|
||||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
try {
|
||||
long bytesDeleted = parseDeleteResponse(response);
|
||||
Metrics.counter(OBJECT_REMOVED_ON_DELETE_COUNTER_NAME,
|
||||
"removed", Boolean.toString(bytesDeleted > 0))
|
||||
.increment();
|
||||
return bytesDeleted;
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.wrap(e);
|
||||
}
|
||||
})
|
||||
.whenComplete((ignored, ignoredException) ->
|
||||
sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, "delete")));
|
||||
}
|
||||
|
||||
private long parseDeleteResponse(final HttpResponse<InputStream> httpDeleteResponse) throws IOException {
|
||||
if (!HttpUtils.isSuccessfulResponse(httpDeleteResponse.statusCode())) {
|
||||
throw new IOException("Failed to retrieve usage: " + httpDeleteResponse.statusCode());
|
||||
}
|
||||
return SystemMapper.jsonMapper().readValue(httpDeleteResponse.body(), DeleteResponse.class).bytesDeleted();
|
||||
}
|
||||
|
||||
private String deleteUrl(final String key) {
|
||||
return "%s/%s/%s".formatted(storageManagerBaseUrl, Cdn3BackupCredentialGenerator.CDN_PATH, key);
|
||||
}
|
||||
|
||||
private String usageUrl() {
|
||||
return "%s/usage".formatted(storageManagerBaseUrl);
|
||||
}
|
||||
|
||||
private String listUrl() {
|
||||
return "%s/%s/".formatted(storageManagerBaseUrl, Cdn3BackupCredentialGenerator.CDN_PATH);
|
||||
}
|
||||
|
||||
private String copyUrl() {
|
||||
return "%s/copy".formatted(storageManagerBaseUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
/**
|
||||
* Descriptor for a single copy-and-encrypt operation
|
||||
*
|
||||
* @param sourceCdn The cdn of the object to copy
|
||||
* @param sourceKey The mediaId within the cdn of the object to copy
|
||||
* @param sourceLength The length of the object to copy
|
||||
* @param encryptionParameters Encryption parameters to double encrypt the object
|
||||
* @param destinationMediaId The mediaId of the destination object
|
||||
*/
|
||||
public record CopyParameters(
|
||||
int sourceCdn,
|
||||
String sourceKey,
|
||||
int sourceLength,
|
||||
MediaEncryptionParameters encryptionParameters,
|
||||
byte[] destinationMediaId) {
|
||||
|
||||
/**
|
||||
* @return The size of the double-encrypted destination object after it is copied
|
||||
*/
|
||||
long destinationObjectSize() {
|
||||
return encryptionParameters().outputSize(sourceLength());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/**
|
||||
* The result of a copy operation
|
||||
*
|
||||
* @param outcome Whether the copy was a success
|
||||
* @param mediaId The destination mediaId
|
||||
* @param cdn On success, the destination cdn
|
||||
*/
|
||||
public record CopyResult(Outcome outcome, byte[] mediaId, @Nullable Integer cdn) {
|
||||
|
||||
public enum Outcome {
|
||||
SUCCESS,
|
||||
SOURCE_NOT_FOUND,
|
||||
SOURCE_WRONG_LENGTH,
|
||||
OUT_OF_QUOTA
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an exception returned by {@link RemoteStorageManager#copy} to CopyResult with the appropriate outcome.
|
||||
*
|
||||
* @param throwable result of a failed copy operation
|
||||
* @param key the copy destination mediaId
|
||||
* @return The appropriate CopyResult, or empty if the exception does not match to an Outcome.
|
||||
*/
|
||||
static Optional<CopyResult> fromCopyError(final Throwable throwable, final byte[] key) {
|
||||
final Throwable unwrapped = ExceptionUtils.unwrap(throwable);
|
||||
if (unwrapped instanceof SourceObjectNotFoundException) {
|
||||
return Optional.of(new CopyResult(Outcome.SOURCE_NOT_FOUND, key, null));
|
||||
} else if (unwrapped instanceof InvalidLengthException) {
|
||||
return Optional.of(new CopyResult(Outcome.SOURCE_WRONG_LENGTH, key, null));
|
||||
} else {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Represents a backup that requires some or all of its content to be deleted
|
||||
*
|
||||
* @param hashedBackupId The hashedBackupId that owns this content
|
||||
* @param expirationType What triggered the expiration
|
||||
* @param lastRefresh The timestamp of the last time the backup user was seen
|
||||
* @param prefixToDelete The prefix on the CDN associated with this backup that should be deleted
|
||||
*/
|
||||
public record ExpiredBackup(
|
||||
byte[] hashedBackupId,
|
||||
ExpirationType expirationType,
|
||||
Instant lastRefresh,
|
||||
String prefixToDelete) {
|
||||
|
||||
public enum ExpirationType {
|
||||
// The prefixToDelete expiration is for the entire backup
|
||||
ALL,
|
||||
// The prefixToDelete is for the media associated with the backup
|
||||
MEDIA,
|
||||
// The prefixToDelete is from a prior expiration attempt
|
||||
GARBAGE_COLLECTION
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class InvalidLengthException extends IOException {
|
||||
|
||||
public InvalidLengthException(String s) {
|
||||
super(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public record MediaEncryptionParameters(
|
||||
SecretKeySpec aesEncryptionKey,
|
||||
SecretKeySpec hmacSHA256Key) {
|
||||
|
||||
public MediaEncryptionParameters(byte[] encryptionKey, byte[] macKey) {
|
||||
this(
|
||||
new SecretKeySpec(encryptionKey, "AES"),
|
||||
new SecretKeySpec(macKey, "HmacSHA256"));
|
||||
}
|
||||
|
||||
public int outputSize(final int inputSize) {
|
||||
// AES-256 has 16-byte block size, and always adds a block if the plaintext is a multiple of the block size
|
||||
final int numBlocks = (inputSize + 16) / 16;
|
||||
// 16-byte IV will be generated and prepended to the ciphertext
|
||||
// IV + AES-256 encrypted data + HmacSHA256
|
||||
return 16 + (numBlocks * 16) + 32;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class PublicKeyConflictException extends IOException {
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* Handles management operations over a external cdn storage system.
|
||||
*/
|
||||
public interface RemoteStorageManager {
|
||||
|
||||
/**
|
||||
* @return The cdn number that this RemoteStorageManager manages
|
||||
*/
|
||||
int cdnNumber();
|
||||
|
||||
/**
|
||||
* Copy and the object from a remote source into the backup, adding an additional layer of encryption
|
||||
*
|
||||
* @param sourceCdn The cdn number where the source attachment is stored
|
||||
* @param sourceKey The key of the source attachment within the attachment cdn
|
||||
* @param expectedSourceLength The length of the source object, should match the content-length of the object returned
|
||||
* from the sourceUri.
|
||||
* @param encryptionParameters The encryption keys that should be used to apply an additional layer of encryption to
|
||||
* the object
|
||||
* @param dstKey The key within the backup cdn where the copied object will be written
|
||||
* @return A stage that completes successfully when the source has been successfully re-encrypted and copied into
|
||||
* uploadDescriptor. The returned CompletionStage can be completed exceptionally with the following exceptions.
|
||||
* <ul>
|
||||
* <li> {@link InvalidLengthException} If the expectedSourceLength does not match the length of the sourceUri </li>
|
||||
* <li> {@link SourceObjectNotFoundException} If the no object at sourceUri is found </li>
|
||||
* <li> {@link java.io.IOException} If there was a generic IO issue </li>
|
||||
* </ul>
|
||||
*/
|
||||
CompletionStage<Void> copy(
|
||||
int sourceCdn,
|
||||
String sourceKey,
|
||||
int expectedSourceLength,
|
||||
MediaEncryptionParameters encryptionParameters,
|
||||
String dstKey);
|
||||
|
||||
/**
|
||||
* Result of a {@link #list} operation
|
||||
*
|
||||
* @param objects An {@link Entry} for each object returned by the list request
|
||||
* @param cursor An opaque string that can be used to resume listing from where a previous request left off, empty if
|
||||
* the request reached the end of the list of matching objects.
|
||||
*/
|
||||
record ListResult(List<Entry> objects, Optional<String> cursor) {
|
||||
|
||||
/**
|
||||
* An entry representing a remote stored object under a prefix
|
||||
*
|
||||
* @param key The name of the object with the prefix removed
|
||||
* @param length The length of the object in bytes
|
||||
*/
|
||||
record Entry(String key, long length) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* List objects on the remote cdn.
|
||||
*
|
||||
* @param prefix The prefix of the objects to list
|
||||
* @param cursor The cursor returned by a previous call to list, or empty if starting from the first object with the
|
||||
* provided prefix
|
||||
* @param limit The maximum number of items to return in the list
|
||||
* @return A {@link ListResult} of objects that match the prefix.
|
||||
*/
|
||||
CompletionStage<ListResult> list(final String prefix, final Optional<String> cursor, final long limit);
|
||||
|
||||
/**
|
||||
* Calculate the total number of bytes stored by objects with the provided prefix
|
||||
*
|
||||
* @param prefix The prefix of the objects to sum
|
||||
* @return The number of bytes used
|
||||
*/
|
||||
CompletionStage<UsageInfo> calculateBytesUsed(final String prefix);
|
||||
|
||||
/**
|
||||
* Delete the specified object.
|
||||
*
|
||||
* @param key the key of the stored object to delete.
|
||||
* @return the number of bytes freed by the deletion operation
|
||||
*/
|
||||
CompletionStage<Long> delete(final String key);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
|
||||
import java.time.Clock;
|
||||
|
||||
public class SecureValueRecoveryBCredentialsGeneratorFactory {
|
||||
private SecureValueRecoveryBCredentialsGeneratorFactory() {}
|
||||
|
||||
|
||||
@VisibleForTesting
|
||||
static ExternalServiceCredentialsGenerator svrbCredentialsGenerator(
|
||||
final SecureValueRecoveryConfiguration cfg,
|
||||
final Clock clock) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())
|
||||
.prependUsername(false)
|
||||
.withDerivedUsernameTruncateLength(16)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ExternalServiceCredentialsGenerator svrbCredentialsGenerator(final SecureValueRecoveryConfiguration cfg) {
|
||||
return svrbCredentialsGenerator(cfg, Clock.systemUTC());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class SourceObjectNotFoundException extends IOException {
|
||||
public SourceObjectNotFoundException() {
|
||||
super();
|
||||
}
|
||||
public SourceObjectNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user