mirror of
https://github.com/signalapp/Signal-Server.git
synced 2025-12-15 02:00:48 +00:00
Compare commits
523 Commits
v20241010.
...
v20250715.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
4
.github/workflows/documentation.yml
vendored
4
.github/workflows/documentation.yml
vendored
@@ -11,8 +11,8 @@ jobs:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
21
.github/workflows/integration-tests.yml
vendored
21
.github/workflows/integration-tests.yml
vendored
@@ -5,30 +5,33 @@ on:
|
||||
- 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_TESTS_BUCKET != '' }}
|
||||
if: ${{ vars.INTEGRATION_TEST_CONFIG != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'maven'
|
||||
- uses: aws-actions/configure-aws-credentials@5579c002bb4778aa43395ef1df492868a9a1c83f # v4.0.2
|
||||
- uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
|
||||
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
|
||||
|
||||
39
.github/workflows/test.yml
vendored
39
.github/workflows/test.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Service CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- gh-pages
|
||||
@@ -9,11 +10,19 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container: ubuntu:22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
services:
|
||||
foundationdb:
|
||||
# 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 foundationdb
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -24,6 +33,28 @@ jobs:
|
||||
HOME: /root
|
||||
- name: Install APT packages
|
||||
# ca-certificates: required for AWS CRT client
|
||||
run: apt update && apt install -y ca-certificates
|
||||
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 FoundationDB database
|
||||
run: docker exec foundationdb /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.serviceContainerName=foundationdb
|
||||
|
||||
4
.mvn/wrapper/maven-wrapper.properties
vendored
4
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -14,7 +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.9.8/apache-maven-3.9.8-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar
|
||||
distributionSha256Sum=8351955a9acf2f83c136c4eee0f6db894ab6265fdbe0a94b32a380307dbaa3e1
|
||||
distributionSha256Sum=4ec3f26fb1a692473aea0235c300bd20f0f9fe741947c82c1234cefd76ac3a3c
|
||||
wrapperSha256Sum=3d8f20ce6103913be8b52aef6d994e0c54705fb527324ceb9b835b338739c7a8
|
||||
|
||||
27
README.md
27
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-2024 Signal Messenger, LLC
|
||||
Copyright 2013 Signal Messenger, LLC
|
||||
|
||||
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
[community forum]: https://community.signalusers.org
|
||||
|
||||
@@ -20,7 +20,7 @@ goal with the `test-server` profile activated:
|
||||
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 `test.test.registration.test`
|
||||
- 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
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-maven-plugin</artifactId>
|
||||
<artifactId>swagger-maven-plugin-jakarta</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<configuration>
|
||||
<outputFileName>signal-server-openapi</outputFileName>
|
||||
|
||||
@@ -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,7 +21,6 @@ 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.AuthenticatedDevice;
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,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
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**</exclude>
|
||||
@@ -39,7 +38,6 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.3.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>
|
||||
|
||||
@@ -8,10 +8,12 @@ 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;
|
||||
@@ -19,7 +21,6 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessions;
|
||||
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,6 +28,8 @@ public class IntegrationTools {
|
||||
|
||||
private final VerificationSessionManager verificationSessionManager;
|
||||
|
||||
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
|
||||
|
||||
|
||||
public static IntegrationTools create(final Config config) {
|
||||
final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
|
||||
@@ -34,30 +37,32 @@ public class IntegrationTools {
|
||||
final DynamoDbAsyncClient dynamoDbAsyncClient =
|
||||
config.dynamoDbClient().buildAsyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());
|
||||
|
||||
final DynamoDbClient dynamoDbClient =
|
||||
config.dynamoDbClient().buildSyncClient(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;
|
||||
@@ -26,8 +29,6 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import io.dropwizard.configuration.ConfigurationValidationException;
|
||||
import io.dropwizard.jersey.validation.Validators;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
@@ -52,7 +53,6 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HttpUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import javax.validation.ConstraintViolation;
|
||||
|
||||
public final class Operations {
|
||||
|
||||
@@ -72,14 +72,12 @@ public final class Operations {
|
||||
}
|
||||
|
||||
public static TestUser newRegisteredUser(final String number) {
|
||||
final byte[] registrationPassword = randomBytes(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();
|
||||
|
||||
final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair();
|
||||
final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair();
|
||||
|
||||
@@ -108,6 +106,7 @@ public final class Operations {
|
||||
}
|
||||
|
||||
public record PrescribedVerificationNumber(String number, String verificationCode) {}
|
||||
|
||||
public static PrescribedVerificationNumber prescribedVerificationNumber() {
|
||||
return new PrescribedVerificationNumber(
|
||||
CONFIG.prescribedRegistrationNumber(),
|
||||
@@ -123,6 +122,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,
|
||||
@@ -329,15 +335,15 @@ public final class Operations {
|
||||
}
|
||||
}
|
||||
|
||||
private static ECSignedPreKey generateSignedECPreKey(long id, final ECKeyPair identityKeyPair) {
|
||||
public static ECSignedPreKey generateSignedECPreKey(final 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ 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.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;
|
||||
@@ -126,7 +128,7 @@ public class TestUser {
|
||||
}
|
||||
|
||||
public AccountAttributes accountAttributes() {
|
||||
return new AccountAttributes(true, registrationId, pniRegistrationId, "".getBytes(StandardCharsets.UTF_8), "", 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);
|
||||
}
|
||||
@@ -161,15 +163,19 @@ public class TestUser {
|
||||
: 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,9 +5,9 @@
|
||||
|
||||
package org.signal.integration.config;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;
|
||||
|
||||
public record Config(@NotBlank String domain,
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
package org.signal.integration.config;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record DynamoDbTables(@NotBlank String registrationRecovery,
|
||||
@NotBlank String verificationSessions) {
|
||||
@NotBlank String verificationSessions,
|
||||
@NotBlank String phoneNumberIdentifiers) {
|
||||
}
|
||||
|
||||
@@ -6,27 +6,35 @@
|
||||
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.Curve;
|
||||
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.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,7 +47,7 @@ public class AccountTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateAccountAtomic() throws Exception {
|
||||
public void testCreateAccountAtomic() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550201");
|
||||
try {
|
||||
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
|
||||
@@ -51,6 +59,33 @@ public class AccountTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changePhoneNumber() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550301");
|
||||
final String targetNumber = "+19995550302";
|
||||
|
||||
final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair();
|
||||
|
||||
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");
|
||||
|
||||
@@ -8,7 +8,6 @@ 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.api.Test;
|
||||
@@ -27,11 +26,10 @@ public class MessagingTest {
|
||||
|
||||
try {
|
||||
final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
|
||||
final String contentBase64 = Base64.getEncoder().encodeToString(expectedContent);
|
||||
final IncomingMessage message = new IncomingMessage(1, Device.PRIMARY_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);
|
||||
@@ -40,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);
|
||||
|
||||
220
pom.xml
220
pom.xml
@@ -37,44 +37,60 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<aws.sdk2.version>2.23.8</aws.sdk2.version>
|
||||
<braintree.version>3.34.0</braintree.version>
|
||||
<commons-csv.version>1.11.0</commons-csv.version>
|
||||
<commons-io.version>2.16.1</commons-io.version>
|
||||
<dropwizard.version>3.0.7</dropwizard.version>
|
||||
<dropwizard-metrics-datadog.version>1.1.13</dropwizard-metrics-datadog.version>
|
||||
<dynamodblocal.version>1.23.0</dynamodblocal.version>
|
||||
<google-cloud-libraries.version>26.33.0</google-cloud-libraries.version>
|
||||
<grpc.version>1.61.1</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
|
||||
<gson.version>2.11.0</gson.version>
|
||||
<aws.sdk2.version>2.31.70</aws.sdk2.version>
|
||||
<braintree.version>3.42.0</braintree.version>
|
||||
<commons-csv.version>1.14.0</commons-csv.version>
|
||||
<commons-io.version>2.19.0</commons-io.version>
|
||||
<dropwizard.version>4.0.12</dropwizard.version>
|
||||
<dropwizard-metrics-datadog.version>1.1.14</dropwizard-metrics-datadog.version>
|
||||
<!-- can be updated to latest version with Dropwizard 5 (Jetty 12); will then need to disable telemetry -->
|
||||
<dynamodblocal.version>2.2.1</dynamodblocal.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.62.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.1</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.17.2</jackson.version>
|
||||
<jaxb.version>2.3.1</jaxb.version>
|
||||
<junit-pioneer.version>2.2.0</junit-pioneer.version>
|
||||
<jackson.version>2.19.1</jackson.version>
|
||||
<junit-pioneer.version>2.3.0</junit-pioneer.version>
|
||||
<jsr305.version>3.0.2</jsr305.version>
|
||||
<kotlin.version>1.9.24</kotlin.version>
|
||||
<kotlinx-serialization.version>1.5.1</kotlinx-serialization.version>
|
||||
<lettuce.version>6.3.2.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>8.13.40</libphonenumber.version>
|
||||
<logstash.logback.version>7.3</logstash.logback.version>
|
||||
<log4j-bom.version>2.23.1</log4j-bom.version>
|
||||
<luajava.version>3.4.0</luajava.version>
|
||||
<micrometer.version>1.13.2</micrometer.version>
|
||||
<netty.version>4.1.111.Final</netty.version>
|
||||
<protobuf.version>3.25.5</protobuf.version> <!-- must be greater than or equal to the value from Google libraries-bom, see https://protobuf.dev/support/cross-version-runtime-guarantee/ -->
|
||||
<kotlin.version>2.2.0</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.5</logback-access-common.version>
|
||||
<lettuce.version>6.7.1.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>9.0.8</libphonenumber.version>
|
||||
<logstash.logback.version>8.1</logstash.logback.version>
|
||||
<log4j-bom.version>2.25.0</log4j-bom.version>
|
||||
<luajava.version>3.5.0</luajava.version>
|
||||
<micrometer.version>1.15.1</micrometer.version>
|
||||
<netty.version>4.1.122.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>
|
||||
<reactor-bom.version>2023.0.8</reactor-bom.version> <!-- 3.6.x, see https://github.com/reactor/reactor#bom-versioning-scheme -->
|
||||
<resilience4j.version>2.2.0</resilience4j.version>
|
||||
<reactor-bom.version>2024.0.7</reactor-bom.version> <!-- 3.7.4, 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>2.0.13</slf4j.version>
|
||||
<simple-grpc.version>0.1.0</simple-grpc.version>
|
||||
<slf4j.version>2.0.17</slf4j.version>
|
||||
<stripe.version>23.10.0</stripe.version>
|
||||
<swagger.version>2.2.22</swagger.version>
|
||||
<swagger.version>2.2.31</swagger.version>
|
||||
<testcontainers.version>1.21.2</testcontainers.version>
|
||||
|
||||
<!-- 21.0.4_7-jre-jammy (note: always use the multi-arch manifest *LIST* here) -->
|
||||
<docker.image.sha256>870aae69d4521fdaf26e952f8026f75b37cb721e6302d4d4d7100f6b09823057</docker.image.sha256>
|
||||
<!-- image to use in tests that run localstack via docker. -->
|
||||
<localstack.image>localstack/localstack:3.5.0</localstack.image>
|
||||
|
||||
<!-- eclipse-temurin:21.0.6_7-jre-jammy (note: always use the multi-arch manifest *LIST* here) -->
|
||||
<docker.image.sha256>02fc89fa8766a9ba221e69225f8d1c10bb91885ddbd3c112448e23488ba40ab6</docker.image.sha256>
|
||||
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
@@ -170,11 +186,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>
|
||||
@@ -195,11 +206,6 @@
|
||||
<artifactId>lettuce-core</artifactId>
|
||||
<version>${lettuce.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>
|
||||
@@ -216,10 +222,9 @@
|
||||
<version>${dropwizard-metrics-datadog.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jaxb</groupId>
|
||||
<artifactId>jaxb-runtime</artifactId>
|
||||
<version>${jaxb.version}</version>
|
||||
<scope>runtime</scope>
|
||||
<groupId>org.foundationdb</groupId>
|
||||
<artifactId>fdb-java</artifactId>
|
||||
<version>${foundationdb.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
@@ -235,12 +240,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.5</version>
|
||||
<version>9.7.1 </version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -266,13 +271,18 @@
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>embedded-redis</artifactId>
|
||||
<version>0.9.0</version>
|
||||
<version>0.9.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>libsignal-server</artifactId>
|
||||
<version>0.54.2</version>
|
||||
<version>0.67.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>simple-grpc-runtime</artifactId>
|
||||
<version>${simple-grpc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.signal.forks</groupId>
|
||||
@@ -302,6 +312,34 @@
|
||||
<version>${dynamodblocal.version}</version>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
|
||||
@@ -318,9 +356,8 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<!-- use standalone until Dropwizard 4 + jakarta.* -->
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<artifactId>wiremock</artifactId>
|
||||
<version>3.12.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -383,7 +420,42 @@
|
||||
<plugin>
|
||||
<groupId>com.google.cloud.tools</groupId>
|
||||
<artifactId>jib-maven-plugin</artifactId>
|
||||
<version>3.4.3</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>
|
||||
@@ -395,7 +467,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>
|
||||
|
||||
@@ -407,6 +479,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>
|
||||
@@ -446,35 +526,21 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>3.7.1</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.3.0</version>
|
||||
<configuration>
|
||||
<systemProperties>
|
||||
<property>
|
||||
<name>sqlite4java.library.path</name>
|
||||
<value>${project.build.directory}/lib</value>
|
||||
</property>
|
||||
</systemProperties>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
@@ -490,7 +556,7 @@
|
||||
<rules>
|
||||
<dependencyConvergence/>
|
||||
<requireMavenVersion>
|
||||
<version>3.8.6</version>
|
||||
<version>3.9.9</version>
|
||||
</requireMavenVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
@@ -501,7 +567,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-install-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<version>3.1.3</version>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
@@ -510,7 +576,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<version>3.1.3</version>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
|
||||
@@ -16,14 +16,11 @@ directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789
|
||||
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
|
||||
|
||||
svr3.userAuthenticationTokenSharedSecret: cbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR3 to generate auth tokens for Signal users
|
||||
svr3.userIdTokenSharedSecret: dbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR3 to generate auth identity tokens for Signal users
|
||||
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
|
||||
|
||||
tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=
|
||||
|
||||
awsAttachments.accessKey: test
|
||||
awsAttachments.accessSecret: test
|
||||
|
||||
gcpAttachments.rsaSigningKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
@@ -72,10 +69,15 @@ cdn.accessSecret: test # AWS Access Secret
|
||||
|
||||
cdn3StorageManager.clientSecret: test
|
||||
|
||||
unidentifiedDelivery.certificate: ABCD1234
|
||||
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=
|
||||
|
||||
@@ -87,15 +89,13 @@ backupsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijkl
|
||||
|
||||
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=
|
||||
|
||||
@@ -92,6 +92,14 @@ appleAppStore:
|
||||
productIdToLevel: {}
|
||||
appleRootCerts: []
|
||||
|
||||
appleDeviceCheck:
|
||||
production: false
|
||||
teamId: 0123456789
|
||||
bundleId: bundle.name
|
||||
|
||||
deviceCheck:
|
||||
backupRedemptionDuration: P30D
|
||||
backupRedemptionLevel: 201
|
||||
|
||||
dynamoDbClient:
|
||||
region: us-west-2 # AWS Region
|
||||
@@ -103,6 +111,10 @@ dynamoDbTables:
|
||||
phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers
|
||||
usernamesTableName: Example_Accounts_Usernames
|
||||
usedLinkDeviceTokensTableName: Example_Accounts_UsedLinkDeviceTokens
|
||||
appleDeviceChecks:
|
||||
tableName: Example_AppleDeviceChecks
|
||||
appleDeviceCheckPublicKeys:
|
||||
tableName: Example_AppleDeviceCheckPublicKeys
|
||||
backups:
|
||||
tableName: Example_Backups
|
||||
clientReleases:
|
||||
@@ -115,12 +127,19 @@ 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:
|
||||
@@ -157,10 +176,11 @@ dynamoDbTables:
|
||||
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
|
||||
@@ -205,10 +225,10 @@ svr2:
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
svr3:
|
||||
uri: svr3.example.com
|
||||
userAuthenticationTokenSharedSecret: secret://svr3.userAuthenticationTokenSharedSecret
|
||||
userIdTokenSharedSecret: secret://svr3.userIdTokenSharedSecret
|
||||
svrb:
|
||||
uri: svrb.example.com
|
||||
userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret
|
||||
userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret
|
||||
svrCaCertificates:
|
||||
- |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
@@ -233,19 +253,11 @@ svr3:
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
|
||||
messageCache: # Redis server configuration for message store cache
|
||||
persistDelayMinutes: 1
|
||||
cluster:
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
|
||||
awsAttachments: # AWS S3 configuration
|
||||
bucket: aws-attachments
|
||||
credentials:
|
||||
accessKeyId: secret://awsAttachments.accessKey
|
||||
secretAccessKey: secret://awsAttachments.accessSecret
|
||||
region: us-west-2
|
||||
|
||||
gcpAttachments: # GCP Storage configuration
|
||||
domain: example.com
|
||||
email: user@example.cocm
|
||||
@@ -287,13 +299,10 @@ dogstatsd:
|
||||
host: 127.0.0.1
|
||||
|
||||
unidentifiedDelivery:
|
||||
certificate: secret://unidentifiedDelivery.certificate
|
||||
certificate: CgIIAQ==
|
||||
privateKey: secret://unidentifiedDelivery.privateKey
|
||||
expiresDays: 7
|
||||
|
||||
hCaptcha:
|
||||
apiKey: secret://hCaptcha.apiKey
|
||||
|
||||
shortCode:
|
||||
baseUrl: https://example.com/shortcodes/
|
||||
|
||||
@@ -334,10 +343,12 @@ callingZkConfig:
|
||||
backupsZkConfig:
|
||||
serverSecret: secret://backupsZkConfig.serverSecret
|
||||
|
||||
appConfig:
|
||||
application: example
|
||||
environment: example
|
||||
configuration: example
|
||||
dynamicConfig:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: dynamic-config.yaml
|
||||
maxSize: 100000
|
||||
refreshInterval: PT10S
|
||||
|
||||
remoteConfig:
|
||||
globalConfig: # keys and values that are given to clients on GET /v1/config
|
||||
@@ -350,13 +361,9 @@ paymentsService:
|
||||
- MOB
|
||||
externalClients:
|
||||
fixerApiKey: secret://paymentsService.fixerApiKey
|
||||
coinMarketCapApiKey: secret://paymentsService.coinMarketCapApiKey
|
||||
coinMarketCapCurrencyIds:
|
||||
MOB: 7878
|
||||
|
||||
artService:
|
||||
userAuthenticationTokenSharedSecret: secret://artService.userAuthenticationTokenSharedSecret
|
||||
userAuthenticationTokenUserIdSecret: secret://artService.userAuthenticationTokenUserIdSecret
|
||||
coinGeckoApiKey: secret://paymentsService.coinGeckoApiKey
|
||||
coinGeckoCurrencyIds:
|
||||
MOB: mobilecoin
|
||||
|
||||
badges:
|
||||
badges:
|
||||
@@ -426,6 +433,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
|
||||
@@ -474,9 +482,31 @@ keyTransparencyService:
|
||||
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
|
||||
|
||||
turn:
|
||||
secret: secret://turn.secret
|
||||
cloudflare:
|
||||
apiToken: secret://turn.cloudflare.apiToken
|
||||
endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
|
||||
@@ -486,38 +516,17 @@ turn:
|
||||
- turn:%s
|
||||
- turn:%s:80?transport=tcp
|
||||
- turns:%s:443?transport=tcp
|
||||
ttl: 86400
|
||||
requestedCredentialTtl: PT24H
|
||||
clientCredentialTtl: PT12H
|
||||
hostname: turn.cloudflare.example.com
|
||||
numHttpClients: 1
|
||||
|
||||
linkDevice:
|
||||
secret: secret://linkDevice.secret
|
||||
|
||||
maxmindCityDatabase:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: an-object.tar.gz
|
||||
maxSize: 32777216
|
||||
|
||||
callingTurnDnsRecords:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: an-object.tar.gz
|
||||
maxSize: 32777216
|
||||
|
||||
callingTurnPerformanceTable:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: an-object.tar.gz
|
||||
maxSize: 32777216
|
||||
|
||||
callingTurnManualTable:
|
||||
s3Region: a-region
|
||||
s3Bucket: a-bucket
|
||||
objectKey: an-object.tar.gz
|
||||
maxSize: 32777216
|
||||
|
||||
noiseTunnel:
|
||||
port: 8443
|
||||
webSocketPort: 8444
|
||||
directPort: 8445
|
||||
tlsKeyStoreFile: /path/to/file.p12
|
||||
tlsKeyStoreEntryAlias: example.com
|
||||
tlsKeyStorePassword: secret://noiseTunnel.tlsKeyStorePassword
|
||||
@@ -531,3 +540,6 @@ externalRequestFilter:
|
||||
- /example
|
||||
permittedInternalRanges:
|
||||
- 127.0.0.0/8
|
||||
|
||||
idlePrimaryDeviceReminder:
|
||||
minIdleDuration: P30D
|
||||
|
||||
165
service/pom.xml
165
service/pom.xml
@@ -11,14 +11,20 @@
|
||||
<artifactId>service</artifactId>
|
||||
|
||||
<properties>
|
||||
<firebase-admin.version>9.2.0</firebase-admin.version>
|
||||
<firebase-admin.version>9.4.3</firebase-admin.version>
|
||||
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
|
||||
<sqlite4java.version>1.0.392</sqlite4java.version>
|
||||
<google-androidpublisher.version>v3-rev20240820-2.0.0</google-androidpublisher.version>
|
||||
<storekit.version>3.1.0</storekit.version>
|
||||
<google-androidpublisher.version>v3-rev20250318-2.0.0</google-androidpublisher.version>
|
||||
<storekit.version>3.4.0</storekit.version>
|
||||
<webauthn4j.version>0.28.6.RELEASE</webauthn4j.version>
|
||||
<java-jwt.version>4.5.0</java-jwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<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>
|
||||
@@ -36,10 +42,22 @@
|
||||
</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</artifactId>
|
||||
<artifactId>swagger-jaxrs2-jakarta</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<exclusions>
|
||||
<!-- conflicts with jackson-dataformat-yaml -->
|
||||
<exclusion>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
@@ -69,6 +87,11 @@
|
||||
<artifactId>noise-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>simple-grpc-runtime</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.dropwizard</groupId>
|
||||
<artifactId>dropwizard-core</artifactId>
|
||||
@@ -132,8 +155,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>
|
||||
@@ -168,10 +191,6 @@
|
||||
<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>
|
||||
@@ -224,36 +243,38 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.26.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.maxmind.geoip2</groupId>
|
||||
<artifactId>geoip2</artifactId>
|
||||
<version>4.2.0</version>
|
||||
<version>1.27.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-pubsub</artifactId>
|
||||
<exclusions>
|
||||
<!-- Conflicts with our direct Guava dependency -->
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>failureaccess</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- resolve opentelemetry-semconv conflicts from lower in firebase-admin and firestore dependency trees -->
|
||||
<dependency>
|
||||
<groupId>com.google.firebase</groupId>
|
||||
<artifactId>firebase-admin</artifactId>
|
||||
<version>${firebase-admin.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<!-- fix dependency convergence from older 1.0.1 -->
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>failureaccess</artifactId>
|
||||
<groupId>io.opentelemetry.semconv</groupId>
|
||||
<artifactId>opentelemetry-semconv</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry.semconv</groupId>
|
||||
<artifactId>opentelemetry-semconv</artifactId>
|
||||
<version>1.30.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-firestore</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>io.opentelemetry.semconv</groupId>
|
||||
<artifactId>opentelemetry-semconv</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
@@ -279,13 +300,6 @@
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-netty</artifactId>
|
||||
<exclusions>
|
||||
<!-- fix dependency convergence from older 0.26.0 -->
|
||||
<exclusion>
|
||||
<groupId>io.perfmark</groupId>
|
||||
<artifactId>perfmark-api</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
@@ -344,6 +358,11 @@
|
||||
<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>
|
||||
@@ -364,18 +383,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>
|
||||
<version>1.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -417,7 +428,7 @@
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>geocoder</artifactId>
|
||||
<version>2.234</version>
|
||||
<version>3.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -447,13 +458,6 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.almworks.sqlite4java</groupId>
|
||||
<artifactId>sqlite4java</artifactId>
|
||||
<version>${sqlite4java.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core</artifactId>
|
||||
@@ -492,11 +496,22 @@
|
||||
<artifactId>DynamoDBLocal</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.github.ganadist.sqlite4java</groupId>
|
||||
<artifactId>libsqlite4java-osx-aarch64</artifactId>
|
||||
<version>${sqlite4java.version}</version>
|
||||
<type>dylib</type>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>localstack</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<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>
|
||||
|
||||
@@ -535,10 +550,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.6.0</version>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>true</createDependencyReducedPom>
|
||||
<filters>
|
||||
@@ -573,7 +609,6 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.7.1</version>
|
||||
<configuration>
|
||||
<descriptors>
|
||||
<descriptor>assembly.xml</descriptor>
|
||||
@@ -593,7 +628,6 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>properties-maven-plugin</artifactId>
|
||||
<version>1.2.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>read-deploy-configuration</id>
|
||||
@@ -659,6 +693,9 @@
|
||||
<includes>*.yml</includes>
|
||||
<into>/usr/share/signal/</into>
|
||||
</path>
|
||||
<path>
|
||||
<from>${project.build.directory}/jib-extra</from>
|
||||
</path>
|
||||
</paths>
|
||||
</extraDirectories>
|
||||
</configuration>
|
||||
@@ -688,7 +725,6 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>start-test-server</id>
|
||||
@@ -728,10 +764,12 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.3.0</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>
|
||||
<systemPropertyVariables>
|
||||
<localstackImage>${localstack.image}</localstackImage>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
@@ -750,7 +788,6 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>check-all-service-config</id>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,17 @@ package org.whispersystems.textsecuregcm;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.dropwizard.core.Configuration;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppleAppStoreConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppleDeviceCheckConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
|
||||
@@ -26,34 +25,34 @@ import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ClientReleaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DefaultAwsCredentialsFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.DeviceCheckConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DogstatsdConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamicConfigurationManagerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
||||
import org.whispersystems.textsecuregcm.configuration.ExternalRequestFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory;
|
||||
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.GooglePlayBillingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory;
|
||||
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.NoiseWebSocketTunnelConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.NoiseTunnelConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.PagedSingleUseKEMPreKeyStoreConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RegistrationServiceClientFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||
@@ -99,6 +98,16 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private AppleAppStoreConfiguration appleAppStore;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AppleDeviceCheckConfiguration appleDeviceCheck;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private DeviceCheckConfiguration deviceCheck;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -109,11 +118,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private DynamoDbTables dynamoDbTables;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AwsAttachmentsConfiguration awsAttachments;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -152,11 +156,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private SecureValueRecovery2Configuration svr2;
|
||||
private SecureValueRecoveryConfiguration svr2;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private SecureValueRecovery3Configuration svr3;
|
||||
private SecureValueRecoveryConfiguration svrb;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@@ -173,11 +178,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private MessageCacheConfiguration messageCache;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private FaultTolerantRedisClusterFactory clientPresenceCluster;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -208,11 +208,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private HCaptchaClientFactory hCaptcha;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -228,11 +223,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private PaymentsServiceConfiguration paymentsService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private ArtServiceConfiguration artService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -256,7 +246,7 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private DynamicConfigurationManagerFactory appConfig;
|
||||
private S3ObjectMonitorFactory dynamicConfig;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -273,6 +263,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
private OneTimeDonationConfiguration oneTimeDonations;
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private PagedSingleUseKEMPreKeyStoreConfiguration pagedSingleUseKEMPreKeyStore;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -317,31 +312,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private VirtualThreadConfiguration virtualThread = new VirtualThreadConfiguration(Duration.ofMillis(1));
|
||||
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private S3ObjectMonitorFactory maxmindCityDatabase;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private S3ObjectMonitorFactory callingTurnDnsRecords;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private S3ObjectMonitorFactory callingTurnPerformanceTable;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private S3ObjectMonitorFactory callingTurnManualTable;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private NoiseWebSocketTunnelConfiguration noiseTunnel;
|
||||
private NoiseTunnelConfiguration noiseTunnel;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -353,6 +327,13 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private KeyTransparencyServiceConfiguration keyTransparencyService;
|
||||
|
||||
@JsonProperty
|
||||
private boolean logMessageDeliveryLoops;
|
||||
|
||||
@JsonProperty
|
||||
private IdlePrimaryDeviceReminderConfiguration idlePrimaryDeviceReminder =
|
||||
new IdlePrimaryDeviceReminderConfiguration(Duration.ofDays(30));
|
||||
|
||||
public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() {
|
||||
return tlsKeyStore;
|
||||
}
|
||||
@@ -377,6 +358,14 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return appleAppStore;
|
||||
}
|
||||
|
||||
public AppleDeviceCheckConfiguration getAppleDeviceCheck() {
|
||||
return appleDeviceCheck;
|
||||
}
|
||||
|
||||
public DeviceCheckConfiguration getDeviceCheck() {
|
||||
return deviceCheck;
|
||||
}
|
||||
|
||||
public DynamoDbClientFactory getDynamoDbClientConfiguration() {
|
||||
return dynamoDbClient;
|
||||
}
|
||||
@@ -385,10 +374,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return dynamoDbTables;
|
||||
}
|
||||
|
||||
public HCaptchaClientFactory getHCaptchaConfiguration() {
|
||||
return hCaptcha;
|
||||
}
|
||||
|
||||
public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {
|
||||
return shortCode;
|
||||
}
|
||||
@@ -397,10 +382,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return webSocket;
|
||||
}
|
||||
|
||||
public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() {
|
||||
return awsAttachments;
|
||||
}
|
||||
|
||||
public GcpAttachmentsConfiguration getGcpAttachmentsConfiguration() {
|
||||
return gcpAttachments;
|
||||
}
|
||||
@@ -413,11 +394,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return pubsub;
|
||||
}
|
||||
|
||||
public SecureValueRecovery2Configuration getSvr2Configuration() {
|
||||
public SecureValueRecoveryConfiguration getSvr2Configuration() {
|
||||
return svr2;
|
||||
}
|
||||
public SecureValueRecovery3Configuration getSvr3Configuration() {
|
||||
return svr3;
|
||||
|
||||
public SecureValueRecoveryConfiguration getSvrbConfiguration() {
|
||||
return svrb;
|
||||
}
|
||||
|
||||
public DirectoryV2Configuration getDirectoryV2Configuration() {
|
||||
@@ -432,10 +414,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return messageCache;
|
||||
}
|
||||
|
||||
public FaultTolerantRedisClusterFactory getClientPresenceClusterConfiguration() {
|
||||
return clientPresenceCluster;
|
||||
}
|
||||
|
||||
public FaultTolerantRedisClusterFactory getPushSchedulerCluster() {
|
||||
return pushSchedulerCluster;
|
||||
}
|
||||
@@ -444,10 +422,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return rateLimitersCluster;
|
||||
}
|
||||
|
||||
public Map<String, RateLimiterConfig> getLimitsConfiguration() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
public FcmConfiguration getFcmConfiguration() {
|
||||
return fcm;
|
||||
}
|
||||
@@ -487,10 +461,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return paymentsService;
|
||||
}
|
||||
|
||||
public ArtServiceConfiguration getArtServiceConfiguration() {
|
||||
return artService;
|
||||
}
|
||||
|
||||
public ZkConfig getZkConfig() {
|
||||
return zkConfig;
|
||||
}
|
||||
@@ -507,8 +477,8 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return remoteConfig;
|
||||
}
|
||||
|
||||
public DynamicConfigurationManagerFactory getAppConfig() {
|
||||
return appConfig;
|
||||
public S3ObjectMonitorFactory getDynamicConfig() {
|
||||
return dynamicConfig;
|
||||
}
|
||||
|
||||
public BadgesConfiguration getBadges() {
|
||||
@@ -523,6 +493,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return oneTimeDonations;
|
||||
}
|
||||
|
||||
public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {
|
||||
return pagedSingleUseKEMPreKeyStore;
|
||||
}
|
||||
|
||||
public ReportMessageConfiguration getReportMessageConfiguration() {
|
||||
return reportMessage;
|
||||
}
|
||||
@@ -559,23 +533,7 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return virtualThread;
|
||||
}
|
||||
|
||||
public S3ObjectMonitorFactory getMaxmindCityDatabase() {
|
||||
return maxmindCityDatabase;
|
||||
}
|
||||
|
||||
public S3ObjectMonitorFactory getCallingTurnDnsRecords() {
|
||||
return callingTurnDnsRecords;
|
||||
}
|
||||
|
||||
public S3ObjectMonitorFactory getCallingTurnPerformanceTable() {
|
||||
return callingTurnPerformanceTable;
|
||||
}
|
||||
|
||||
public S3ObjectMonitorFactory getCallingTurnManualTable() {
|
||||
return callingTurnManualTable;
|
||||
}
|
||||
|
||||
public NoiseWebSocketTunnelConfiguration getNoiseWebSocketTunnelConfiguration() {
|
||||
public NoiseTunnelConfiguration getNoiseTunnelConfiguration() {
|
||||
return noiseTunnel;
|
||||
}
|
||||
|
||||
@@ -586,4 +544,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
public KeyTransparencyServiceConfiguration getKeyTransparencyServiceConfiguration() {
|
||||
return keyTransparencyService;
|
||||
}
|
||||
|
||||
public boolean logMessageDeliveryLoops() {
|
||||
return logMessageDeliveryLoops;
|
||||
}
|
||||
|
||||
public IdlePrimaryDeviceReminderConfiguration idlePrimaryDeviceReminderConfiguration() {
|
||||
return idlePrimaryDeviceReminder;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -15,10 +15,12 @@ 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 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;
|
||||
@@ -112,7 +114,9 @@ public class AccountAuthenticator implements Authenticator<BasicCredentials, Aut
|
||||
device.get(),
|
||||
SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version
|
||||
}
|
||||
return Optional.of(new AuthenticatedDevice(authenticatedAccount, device.get()));
|
||||
return Optional.of(new AuthenticatedDevice(authenticatedAccount.getIdentifier(IdentityType.ACI),
|
||||
device.get().getId(),
|
||||
Instant.ofEpochMilli(authenticatedAccount.getPrimaryDevice().getLastSeen())));
|
||||
} else {
|
||||
failureReason = "incorrectPassword";
|
||||
return Optional.empty();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
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, BackupLevel backupLevel, String backupDir, String mediaDir) {}
|
||||
public record AuthenticatedBackupUser(
|
||||
byte[] backupId,
|
||||
BackupCredentialType credentialType,
|
||||
BackupLevel backupLevel,
|
||||
String backupDir,
|
||||
String mediaDir,
|
||||
@Nullable UserAgent userAgent) {
|
||||
}
|
||||
|
||||
@@ -6,31 +6,12 @@
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import javax.security.auth.Subject;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class AuthenticatedDevice implements Principal, AccountAndAuthenticatedDeviceHolder {
|
||||
|
||||
private final Account account;
|
||||
private final Device device;
|
||||
|
||||
public AuthenticatedDevice(final Account account, final Device device) {
|
||||
this.account = account;
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Device getAuthenticatedDevice() {
|
||||
return device;
|
||||
}
|
||||
|
||||
// Principal implementation
|
||||
public record AuthenticatedDevice(UUID accountIdentifier, byte deviceId, Instant primaryDeviceLastSeen)
|
||||
implements Principal {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
|
||||
@@ -31,9 +31,9 @@ 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()))
|
||||
.setSenderDevice(Math.toIntExact(deviceId))
|
||||
.setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays))
|
||||
.setIdentityKey(ByteString.copyFrom(account.getIdentityKey(IdentityType.ACI).serialize()))
|
||||
.setSigner(serverCertificate)
|
||||
|
||||
@@ -6,22 +6,27 @@
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
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.ws.rs.core.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@@ -29,19 +34,24 @@ public class CloudflareTurnCredentialsManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CloudflareTurnCredentialsManager.class);
|
||||
|
||||
private static final String CREDENTIAL_FETCH_TIMER_NAME = MetricsUtil.name(CloudflareTurnCredentialsManager.class,
|
||||
"credentialFetchLatency");
|
||||
|
||||
private final List<String> cloudflareTurnUrls;
|
||||
private final List<String> cloudflareTurnUrlsWithIps;
|
||||
private final String cloudflareTurnHostname;
|
||||
private final HttpRequest request;
|
||||
private final HttpRequest getCredentialsRequest;
|
||||
|
||||
private final FaultTolerantHttpClient cloudflareTurnClient;
|
||||
private final DnsNameResolver dnsNameResolver;
|
||||
|
||||
record CredentialRequest(long ttl) {}
|
||||
private final Duration clientCredentialTtl;
|
||||
|
||||
record CloudflareTurnResponse(IceServer iceServers) {
|
||||
private record CredentialRequest(long ttl) {}
|
||||
|
||||
record IceServer(
|
||||
private record CloudflareTurnResponse(IceServer iceServers) {
|
||||
|
||||
private record IceServer(
|
||||
String username,
|
||||
String credential,
|
||||
List<String> urls) {
|
||||
@@ -49,10 +59,18 @@ public class CloudflareTurnCredentialsManager {
|
||||
}
|
||||
|
||||
public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,
|
||||
final String cloudflareTurnEndpoint, final long cloudflareTurnTtl, final List<String> cloudflareTurnUrls,
|
||||
final List<String> cloudflareTurnUrlsWithIps, final String cloudflareTurnHostname,
|
||||
final CircuitBreakerConfiguration circuitBreaker, final ExecutorService executor, final RetryConfiguration retry,
|
||||
final ScheduledExecutorService retryExecutor, final DnsNameResolver dnsNameResolver) {
|
||||
final String cloudflareTurnEndpoint,
|
||||
final Duration requestedCredentialTtl,
|
||||
final Duration clientCredentialTtl,
|
||||
final List<String> cloudflareTurnUrls,
|
||||
final List<String> cloudflareTurnUrlsWithIps,
|
||||
final String cloudflareTurnHostname,
|
||||
final int cloudflareTurnNumHttpClients,
|
||||
final CircuitBreakerConfiguration circuitBreaker,
|
||||
final ExecutorService executor,
|
||||
final RetryConfiguration retry,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final DnsNameResolver dnsNameResolver) {
|
||||
|
||||
this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder()
|
||||
.withName("cloudflare-turn")
|
||||
@@ -60,23 +78,31 @@ public class CloudflareTurnCredentialsManager {
|
||||
.withExecutor(executor)
|
||||
.withRetry(retry)
|
||||
.withRetryExecutor(retryExecutor)
|
||||
.withNumClients(cloudflareTurnNumHttpClients)
|
||||
.build();
|
||||
this.cloudflareTurnUrls = cloudflareTurnUrls;
|
||||
this.cloudflareTurnUrlsWithIps = cloudflareTurnUrlsWithIps;
|
||||
this.cloudflareTurnHostname = cloudflareTurnHostname;
|
||||
this.dnsNameResolver = dnsNameResolver;
|
||||
|
||||
final String credentialsRequestBody;
|
||||
|
||||
try {
|
||||
final String body = SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(cloudflareTurnTtl));
|
||||
this.request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(cloudflareTurnEndpoint))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
} catch (JsonProcessingException e) {
|
||||
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 {
|
||||
@@ -93,11 +119,20 @@ public class CloudflareTurnCredentialsManager {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
final Timer.Sample sample = Timer.start();
|
||||
final HttpResponse<String> response;
|
||||
try {
|
||||
response = cloudflareTurnClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
|
||||
response = cloudflareTurnClient.sendAsync(getCredentialsRequest, HttpResponse.BodyHandlers.ofString()).join();
|
||||
sample.stop(Timer.builder(CREDENTIAL_FETCH_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags("outcome", "success")
|
||||
.register(Metrics.globalRegistry));
|
||||
} catch (CompletionException e) {
|
||||
logger.warn("failed to make http request to Cloudflare Turn: {}", e.getMessage());
|
||||
sample.stop(Timer.builder(CREDENTIAL_FETCH_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags("outcome", "failure")
|
||||
.register(Metrics.globalRegistry));
|
||||
throw new IOException(ExceptionUtils.unwrap(e));
|
||||
}
|
||||
|
||||
@@ -109,8 +144,13 @@ public class CloudflareTurnCredentialsManager {
|
||||
final CloudflareTurnResponse cloudflareTurnResponse = SystemMapper.jsonMapper()
|
||||
.readValue(response.body(), CloudflareTurnResponse.class);
|
||||
|
||||
return new TurnToken(cloudflareTurnResponse.iceServers().username(),
|
||||
return new TurnToken(
|
||||
cloudflareTurnResponse.iceServers().username(),
|
||||
cloudflareTurnResponse.iceServers().credential(),
|
||||
cloudflareTurnUrls, cloudflareTurnComposedUrls, cloudflareTurnHostname);
|
||||
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;
|
||||
|
||||
@@ -1,45 +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 org.whispersystems.textsecuregcm.storage.Device;
|
||||
import javax.ws.rs.core.SecurityContext;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
class ContainerRequestUtil {
|
||||
|
||||
/**
|
||||
* A read-only subset of the authenticated Account object, to enforce that filter-based consumers do not perform
|
||||
* account modifying operations.
|
||||
*/
|
||||
record AccountInfo(UUID accountId, String e164, Set<Byte> deviceIds) {
|
||||
|
||||
static AccountInfo fromAccount(final Account account) {
|
||||
return new AccountInfo(
|
||||
account.getUuid(),
|
||||
account.getNumber(),
|
||||
account.getDevices().stream().map(Device::getId).collect(Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
static Optional<AccountInfo> getAuthenticatedAccount(final ContainerRequest request) {
|
||||
return Optional.ofNullable(request.getSecurityContext())
|
||||
.map(SecurityContext::getUserPrincipal)
|
||||
.map(principal -> {
|
||||
if (principal instanceof AccountAndAuthenticatedDeviceHolder aaadh) {
|
||||
return aaadh.getAccount();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.map(AccountInfo::fromAccount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A disconnection request listener receives and handles requests to close authenticated client network connections.
|
||||
*/
|
||||
public interface DisconnectionRequestListener {
|
||||
|
||||
/**
|
||||
* Handles a request to close authenticated network connections for one or more authenticated devices. Requests are
|
||||
* dispatched on dedicated threads, and implementations may safely block.
|
||||
*
|
||||
* @param accountIdentifier the account identifier for which to close authenticated connections
|
||||
* @param deviceIds the device IDs within the identified account for which to close authenticated connections
|
||||
*/
|
||||
void handleDisconnectionRequest(UUID accountIdentifier, Collection<Byte> deviceIds);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import io.lettuce.core.pubsub.RedisPubSubAdapter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executor;
|
||||
import javax.annotation.Nullable;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
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;
|
||||
|
||||
// We expect just a couple listeners to get added at startup time and not at all at steady-state. There are several
|
||||
// reasonable ways to model this, but a copy-on-write list gives us good flexibility with minimal performance cost.
|
||||
private final List<DisconnectionRequestListener> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
@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);
|
||||
|
||||
public DisconnectionRequestManager(final FaultTolerantRedisClient pubSubClient,
|
||||
final Executor listenerEventExecutor) {
|
||||
|
||||
this.pubSubClient = pubSubClient;
|
||||
this.listenerEventExecutor = listenerEventExecutor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() {
|
||||
this.pubSubConnection = pubSubClient.createBinaryPubSubConnection();
|
||||
this.pubSubConnection.usePubSubConnection(connection -> {
|
||||
connection.addListener(this);
|
||||
connection.sync().subscribe(DISCONNECTION_REQUEST_CHANNEL);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
if (pubSubConnection != null) {
|
||||
pubSubConnection.usePubSubConnection(connection -> {
|
||||
connection.removeListener(this);
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
pubSubConnection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener for disconnection requests. Listeners will receive all broadcast disconnection requests regardless
|
||||
* of whether the device in connection is connected to this server.
|
||||
*
|
||||
* @param listener the listener to register
|
||||
*/
|
||||
public void addListener(final DisconnectionRequestListener listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a request to close all connections associated with the given account identifier to all servers.
|
||||
*
|
||||
* @param accountIdentifier the account for which to close connections
|
||||
*
|
||||
* @return a future that completes when the request has been broadcast
|
||||
*/
|
||||
public CompletionStage<Void> requestDisconnection(final UUID accountIdentifier) {
|
||||
return requestDisconnection(accountIdentifier, Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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.getDeviceIdsCount() > 0
|
||||
? 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()
|
||||
: Device.ALL_POSSIBLE_DEVICE_IDS;
|
||||
} 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;
|
||||
}
|
||||
|
||||
for (final DisconnectionRequestListener listener : listeners) {
|
||||
try {
|
||||
listenerEventExecutor.execute(() -> listener.handleDisconnectionRequest(accountIdentifier, deviceIds));
|
||||
} catch (final Exception e) {
|
||||
logger.warn("Listener failed to handle disconnection request", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import java.util.Base64;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
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.util.Pair;
|
||||
|
||||
/**
|
||||
* This {@link WebsocketRefreshRequirementProvider} observes intra-request changes in devices linked to an
|
||||
* {@link Account} and triggers a WebSocket refresh if that set changes. If a change in linked devices 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 AuthenticatedDevice
|
||||
*/
|
||||
public class LinkedDeviceRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LinkedDeviceRefreshRequirementProvider.class);
|
||||
|
||||
private static final String ACCOUNT_UUID = LinkedDeviceRefreshRequirementProvider.class.getName() + ".accountUuid";
|
||||
private static final String LINKED_DEVICE_IDS = LinkedDeviceRefreshRequirementProvider.class.getName() + ".deviceIds";
|
||||
|
||||
public LinkedDeviceRefreshRequirementProvider(final AccountsManager accountsManager) {
|
||||
this.accountsManager = accountsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRequestFiltered(final RequestEvent requestEvent) {
|
||||
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod().getAnnotation(
|
||||
ChangesLinkedDevices.class) != null) {
|
||||
// The authenticated principal, if any, will be available after filters have run. Now that the account is known,
|
||||
// capture a snapshot of the account's linked 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) {
|
||||
setAccount(containerRequest, ContainerRequestUtil.AccountInfo.fromAccount(account));
|
||||
}
|
||||
|
||||
private static void setAccount(final ContainerRequest containerRequest, final ContainerRequestUtil.AccountInfo info) {
|
||||
containerRequest.setProperty(ACCOUNT_UUID, info.accountId());
|
||||
containerRequest.setProperty(LINKED_DEVICE_IDS, info.deviceIds());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<UUID, Byte>> handleRequestFinished(final RequestEvent requestEvent) {
|
||||
// Now that the request is finished, check whether the set of linked devices has changed. If the value did change or
|
||||
// if a devices was added or removed, all devices must disconnect and reauthenticate.
|
||||
if (requestEvent.getContainerRequest().getProperty(LINKED_DEVICE_IDS) != null) {
|
||||
|
||||
@SuppressWarnings("unchecked") final Set<Byte> initialLinkedDeviceIds =
|
||||
(Set<Byte>) requestEvent.getContainerRequest().getProperty(LINKED_DEVICE_IDS);
|
||||
|
||||
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID))
|
||||
.map(ContainerRequestUtil.AccountInfo::fromAccount)
|
||||
.map(accountInfo -> {
|
||||
final Set<Byte> deviceIdsToDisplace;
|
||||
final Set<Byte> currentLinkedDeviceIds = accountInfo.deviceIds();
|
||||
|
||||
if (!initialLinkedDeviceIds.equals(currentLinkedDeviceIds)) {
|
||||
deviceIdsToDisplace = new HashSet<>(initialLinkedDeviceIds);
|
||||
deviceIdsToDisplace.addAll(currentLinkedDeviceIds);
|
||||
} else {
|
||||
deviceIdsToDisplace = Collections.emptySet();
|
||||
}
|
||||
|
||||
return deviceIdsToDisplace.stream()
|
||||
.map(deviceId -> new Pair<>(accountInfo.accountId(), deviceId))
|
||||
.collect(Collectors.toList());
|
||||
}).orElseGet(() -> {
|
||||
logger.error("Request had account, but it is no longer present");
|
||||
return Collections.emptyList();
|
||||
});
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
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 javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
||||
@@ -1,56 +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.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
public class PhoneNumberChangeRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
|
||||
|
||||
private static final String ACCOUNT_UUID =
|
||||
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".accountUuid";
|
||||
|
||||
private static final String INITIAL_NUMBER_KEY =
|
||||
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".initialNumber";
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
public PhoneNumberChangeRefreshRequirementProvider(final AccountsManager accountsManager) {
|
||||
this.accountsManager = accountsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRequestFiltered(final RequestEvent requestEvent) {
|
||||
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod()
|
||||
.getAnnotation(ChangesPhoneNumber.class) == null) {
|
||||
return;
|
||||
}
|
||||
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest())
|
||||
.ifPresent(account -> {
|
||||
requestEvent.getContainerRequest().setProperty(INITIAL_NUMBER_KEY, account.e164());
|
||||
requestEvent.getContainerRequest().setProperty(ACCOUNT_UUID, account.accountId());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<UUID, Byte>> handleRequestFinished(final RequestEvent requestEvent) {
|
||||
final String initialNumber = (String) requestEvent.getContainerRequest().getProperty(INITIAL_NUMBER_KEY);
|
||||
|
||||
if (initialNumber == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -7,24 +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.container.ContainerRequestContext;
|
||||
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 {
|
||||
@@ -33,13 +34,17 @@ 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,
|
||||
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;
|
||||
@@ -109,7 +114,7 @@ public class PhoneVerificationTokenManager {
|
||||
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");
|
||||
|
||||
@@ -12,20 +12,19 @@ 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.entities.Svr3Credentials;
|
||||
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;
|
||||
@@ -54,24 +53,22 @@ public class RegistrationLockVerificationManager {
|
||||
private static final String PHONE_VERIFICATION_TYPE_TAG_NAME = "phoneVerificationType";
|
||||
|
||||
private final AccountsManager accounts;
|
||||
private final ClientPresenceManager clientPresenceManager;
|
||||
private final DisconnectionRequestManager disconnectionRequestManager;
|
||||
private final ExternalServiceCredentialsGenerator svr2CredentialGenerator;
|
||||
private final ExternalServiceCredentialsGenerator svr3CredentialGenerator;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
|
||||
public RegistrationLockVerificationManager(
|
||||
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
|
||||
final AccountsManager accounts,
|
||||
final DisconnectionRequestManager disconnectionRequestManager,
|
||||
final ExternalServiceCredentialsGenerator svr2CredentialGenerator,
|
||||
final ExternalServiceCredentialsGenerator svr3CredentialGenerator,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
final PushNotificationManager pushNotificationManager,
|
||||
final RateLimiters rateLimiters) {
|
||||
this.accounts = accounts;
|
||||
this.clientPresenceManager = clientPresenceManager;
|
||||
this.disconnectionRequestManager = disconnectionRequestManager;
|
||||
this.svr2CredentialGenerator = svr2CredentialGenerator;
|
||||
this.svr3CredentialGenerator = svr3CredentialGenerator;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
@@ -156,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<Byte> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();
|
||||
clientPresenceManager.disconnectAllPresences(updatedAccount.getUuid(), deviceIds);
|
||||
disconnectionRequestManager.requestDisconnection(updatedAccount.getUuid(), deviceIds);
|
||||
|
||||
try {
|
||||
// Send a push notification that prompts the client to attempt login and fail due to locked credentials
|
||||
@@ -172,8 +169,7 @@ public class RegistrationLockVerificationManager {
|
||||
throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
|
||||
.entity(new RegistrationLockFailure(
|
||||
existingRegistrationLock.getTimeRemaining().toMillis(),
|
||||
svr2FailureCredentials(existingRegistrationLock, updatedAccount),
|
||||
svr3FailureCredentials(existingRegistrationLock, updatedAccount)))
|
||||
svr2FailureCredentials(existingRegistrationLock, updatedAccount)))
|
||||
.build());
|
||||
}
|
||||
|
||||
@@ -187,11 +183,4 @@ public class RegistrationLockVerificationManager {
|
||||
return svr2CredentialGenerator.generateForUuid(account.getUuid());
|
||||
}
|
||||
|
||||
private @Nullable Svr3Credentials svr3FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {
|
||||
if (!existingRegistrationLock.needsFailureCredentials()) {
|
||||
return null;
|
||||
}
|
||||
final ExternalServiceCredentials creds = svr3CredentialGenerator.generateForUuid(account.getUuid());
|
||||
return new Svr3Credentials(creds.username(), creds.password(), account.getSvr3ShareSet());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
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, @Nullable List<String> urlsWithIps,
|
||||
@Nullable String hostname) {
|
||||
public record TurnToken(
|
||||
String username,
|
||||
String password,
|
||||
@JsonProperty("ttl") long ttlSeconds,
|
||||
@Nonnull List<String> urls,
|
||||
@Nonnull List<String> urlsWithIps,
|
||||
@Nullable String hostname) {
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
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;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
|
||||
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;
|
||||
|
||||
public class TurnTokenGenerator {
|
||||
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
private final byte[] turnSecret;
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA1";
|
||||
|
||||
private static final String WithUrlsProtocol = "00";
|
||||
|
||||
private static final String WithIpsProtocol = "01";
|
||||
|
||||
public TurnTokenGenerator(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final byte[] turnSecret) {
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.turnSecret = turnSecret;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public TurnToken generate(final UUID aci) {
|
||||
return generateToken(null, null, urls(aci));
|
||||
}
|
||||
|
||||
public TurnToken generateWithTurnServerOptions(TurnServerOptions options) {
|
||||
return generateToken(options.hostname(), options.urlsWithIps(), options.urlsWithHostname());
|
||||
}
|
||||
|
||||
private TurnToken generateToken(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
|
||||
try {
|
||||
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;
|
||||
final String protocol = urlsWithIps != null && !urlsWithIps.isEmpty()
|
||||
? WithIpsProtocol
|
||||
: WithUrlsProtocol;
|
||||
final String protocolUserTime = userTime + "#" + protocol;
|
||||
|
||||
mac.init(new SecretKeySpec(turnSecret, ALGORITHM));
|
||||
final String password = Base64.getEncoder().encodeToString(mac.doFinal(protocolUserTime.getBytes()));
|
||||
|
||||
return new TurnToken(protocolUserTime, password, urlsWithHostname, urlsWithIps, hostname);
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ 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 {
|
||||
|
||||
@@ -31,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 LinkedDeviceRefreshRequirementProvider(accountsManager),
|
||||
new PhoneNumberChangeRefreshRequirementProvider(accountsManager));
|
||||
}
|
||||
|
||||
@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, Byte>> handleRequestFinished(RequestEvent requestEvent);
|
||||
}
|
||||
@@ -1,34 +1,22 @@
|
||||
package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import io.grpc.Grpc;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.netty.channel.local.LocalAddress;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ClientConnectionManager;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.grpc.ChannelNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
abstract class AbstractAuthenticationInterceptor implements ServerInterceptor {
|
||||
|
||||
private final ClientConnectionManager clientConnectionManager;
|
||||
private final GrpcClientConnectionManager grpcClientConnectionManager;
|
||||
|
||||
private static final Metadata EMPTY_TRAILERS = new Metadata();
|
||||
|
||||
AbstractAuthenticationInterceptor(final ClientConnectionManager clientConnectionManager) {
|
||||
this.clientConnectionManager = clientConnectionManager;
|
||||
AbstractAuthenticationInterceptor(final GrpcClientConnectionManager grpcClientConnectionManager) {
|
||||
this.grpcClientConnectionManager = grpcClientConnectionManager;
|
||||
}
|
||||
|
||||
protected Optional<AuthenticatedDevice> getAuthenticatedDevice(final ServerCall<?, ?> call) {
|
||||
if (call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR) instanceof LocalAddress localAddress) {
|
||||
return clientConnectionManager.getAuthenticatedDevice(localAddress);
|
||||
} else {
|
||||
throw new AssertionError("Unexpected channel type: " + call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR));
|
||||
}
|
||||
}
|
||||
protected Optional<AuthenticatedDevice> getAuthenticatedDevice(final ServerCall<?, ?> call)
|
||||
throws ChannelNotFoundException {
|
||||
|
||||
protected <ReqT, RespT> ServerCall.Listener<ReqT> closeAsUnauthenticated(final ServerCall<ReqT, RespT> call) {
|
||||
call.close(Status.UNAUTHENTICATED, EMPTY_TRAILERS);
|
||||
return new ServerCall.Listener<>() {};
|
||||
return grpcClientConnectionManager.getAuthenticatedDevice(call);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ClientConnectionManager;
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.grpc.ChannelNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
/**
|
||||
* A "prohibit authentication" interceptor ensures that requests to endpoints that should be invoked anonymously do not
|
||||
* originate from a channel that is associated with an authenticated device. Calls with an associated authenticated
|
||||
* device are closed with an {@code UNAUTHENTICATED} status.
|
||||
* device are closed with an {@code UNAUTHENTICATED} status. If a call's authentication status cannot be determined
|
||||
* (i.e. because the underlying remote channel closed before the {@code ServerCall} started), the interceptor will
|
||||
* reject the call with a status of {@code UNAVAILABLE}.
|
||||
*/
|
||||
public class ProhibitAuthenticationInterceptor extends AbstractAuthenticationInterceptor {
|
||||
|
||||
public ProhibitAuthenticationInterceptor(final ClientConnectionManager clientConnectionManager) {
|
||||
super(clientConnectionManager);
|
||||
public ProhibitAuthenticationInterceptor(final GrpcClientConnectionManager grpcClientConnectionManager) {
|
||||
super(grpcClientConnectionManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -21,8 +26,15 @@ public class ProhibitAuthenticationInterceptor extends AbstractAuthenticationInt
|
||||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
return getAuthenticatedDevice(call)
|
||||
.map(ignored -> closeAsUnauthenticated(call))
|
||||
.orElseGet(() -> next.startCall(call, headers));
|
||||
try {
|
||||
return getAuthenticatedDevice(call)
|
||||
// Status.INTERNAL may seem a little surprising here, but if a caller is reaching an authentication-prohibited
|
||||
// service via an authenticated connection, then that's actually a server configuration issue and not a
|
||||
// problem with the client's request.
|
||||
.map(ignored -> ServerInterceptorUtil.closeWithStatus(call, Status.INTERNAL))
|
||||
.orElseGet(() -> next.startCall(call, headers));
|
||||
} catch (final ChannelNotFoundException e) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call, Status.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,21 @@ import io.grpc.Contexts;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ClientConnectionManager;
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.grpc.ChannelNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
/**
|
||||
* A "require authentication" interceptor requires that requests be issued from a connection that is associated with an
|
||||
* authenticated device. Calls without an associated authenticated device are closed with an {@code UNAUTHENTICATED}
|
||||
* status.
|
||||
* status. If a call's authentication status cannot be determined (i.e. because the underlying remote channel closed
|
||||
* before the {@code ServerCall} started), the interceptor will reject the call with a status of {@code UNAVAILABLE}.
|
||||
*/
|
||||
public class RequireAuthenticationInterceptor extends AbstractAuthenticationInterceptor {
|
||||
|
||||
public RequireAuthenticationInterceptor(final ClientConnectionManager clientConnectionManager) {
|
||||
super(clientConnectionManager);
|
||||
public RequireAuthenticationInterceptor(final GrpcClientConnectionManager grpcClientConnectionManager) {
|
||||
super(grpcClientConnectionManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -23,10 +27,17 @@ public class RequireAuthenticationInterceptor extends AbstractAuthenticationInte
|
||||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
return getAuthenticatedDevice(call)
|
||||
.map(authenticatedDevice -> Contexts.interceptCall(Context.current()
|
||||
.withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE, authenticatedDevice),
|
||||
call, headers, next))
|
||||
.orElseGet(() -> closeAsUnauthenticated(call));
|
||||
try {
|
||||
return getAuthenticatedDevice(call)
|
||||
.map(authenticatedDevice -> Contexts.interceptCall(Context.current()
|
||||
.withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE, authenticatedDevice),
|
||||
call, headers, next))
|
||||
// Status.INTERNAL may seem a little surprising here, but if a caller is reaching an authentication-required
|
||||
// service via an unauthenticated connection, then that's actually a server configuration issue and not a
|
||||
// problem with the client's request.
|
||||
.orElseGet(() -> ServerInterceptorUtil.closeWithStatus(call, Status.INTERNAL));
|
||||
} catch (final ChannelNotFoundException e) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call, Status.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.stream.Stream;
|
||||
import javax.annotation.Nullable;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
@@ -21,6 +22,7 @@ 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;
|
||||
@@ -29,10 +31,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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;
|
||||
|
||||
@@ -48,7 +50,7 @@ import org.whispersystems.textsecuregcm.util.Util;
|
||||
*/
|
||||
public class BackupAuthManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupAuthManager.class);
|
||||
|
||||
|
||||
final static Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
|
||||
@@ -81,31 +83,57 @@ public class BackupAuthManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential request containing a blinded backup-id for future use.
|
||||
* Store credential requests containing blinded backup-ids for future use.
|
||||
*
|
||||
* @param account The account using the backup-id
|
||||
* @param backupAuthCredentialRequest A request containing the blinded backup-id
|
||||
* @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 BackupAuthCredentialRequest backupAuthCredentialRequest) {
|
||||
public CompletableFuture<Void> commitBackupId(
|
||||
final Account account,
|
||||
final Device device,
|
||||
final BackupAuthCredentialRequest messagesBackupCredentialRequest,
|
||||
final BackupAuthCredentialRequest mediaBackupCredentialRequest) {
|
||||
if (configuredBackupLevel(account).isEmpty()) {
|
||||
throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException();
|
||||
}
|
||||
if (!device.isPrimary()) {
|
||||
throw Status.PERMISSION_DENIED.withDescription("Only primary device can set backup-id").asRuntimeException();
|
||||
}
|
||||
final byte[] serializedMessageCredentialRequest = messagesBackupCredentialRequest.serialize();
|
||||
final byte[] serializedMediaCredentialRequest = mediaBackupCredentialRequest.serialize();
|
||||
|
||||
byte[] serializedRequest = backupAuthCredentialRequest.serialize();
|
||||
byte[] existingRequest = account.getBackupCredentialRequest();
|
||||
if (existingRequest != null && MessageDigest.isEqual(serializedRequest, existingRequest)) {
|
||||
final boolean messageCredentialRequestMatches = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
|
||||
.map(storedCredentialRequest -> MessageDigest.isEqual(storedCredentialRequest, serializedMessageCredentialRequest))
|
||||
.orElse(false);
|
||||
|
||||
final boolean mediaCredentialRequestMatches = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)
|
||||
.map(storedCredentialRequest -> MessageDigest.isEqual(storedCredentialRequest, serializedMediaCredentialRequest))
|
||||
.orElse(false);
|
||||
|
||||
if (messageCredentialRequestMatches && mediaCredentialRequestMatches) {
|
||||
// No need to update or enforce rate limits, this is the credential that the user has already
|
||||
// committed to.
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
return rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID)
|
||||
.validateAsync(account.getUuid())
|
||||
.thenCompose(ignored -> this.accountsManager
|
||||
.updateAsync(account, acc -> acc.setBackupCredentialRequest(serializedRequest))
|
||||
CompletionStage<Void> rateLimitFuture = rateLimiters
|
||||
.forDescriptor(RateLimiters.For.SET_BACKUP_ID)
|
||||
.validateAsync(account.getUuid());
|
||||
|
||||
if (!mediaCredentialRequestMatches && hasActiveVoucher(account)) {
|
||||
rateLimitFuture = rateLimitFuture.thenCombine(
|
||||
rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID).validateAsync(account.getUuid()),
|
||||
(ignore1, ignore2) -> null);
|
||||
}
|
||||
|
||||
return rateLimitFuture.thenCompose(ignored -> this.accountsManager
|
||||
.updateAsync(account, a -> a.setBackupCredentialRequests(serializedMessageCredentialRequest, serializedMediaCredentialRequest))
|
||||
.thenRun(Util.NOOP))
|
||||
.toCompletableFuture();
|
||||
}
|
||||
@@ -123,12 +151,14 @@ public class BackupAuthManager {
|
||||
* 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 redemptionStart The day (must be truncated to a day boundary) the first credential should be valid
|
||||
* @param redemptionEnd The day (must be truncated to a day boundary) the last credential should be valid
|
||||
* @return Credentials and the day on which they may be redeemed
|
||||
*/
|
||||
public CompletableFuture<List<Credential>> getBackupAuthCredentials(
|
||||
final Account account,
|
||||
final BackupCredentialType credentialType,
|
||||
final Instant redemptionStart,
|
||||
final Instant redemptionEnd) {
|
||||
|
||||
@@ -139,7 +169,7 @@ public class BackupAuthManager {
|
||||
if (hasExpiredVoucher(a)) {
|
||||
a.setBackupVoucher(null);
|
||||
}
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, redemptionStart, redemptionEnd));
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, credentialType, redemptionStart, redemptionEnd));
|
||||
}
|
||||
|
||||
// If this account isn't allowed some level of backup access via configuration, don't continue
|
||||
@@ -157,23 +187,20 @@ public class BackupAuthManager {
|
||||
}
|
||||
|
||||
// fetch the blinded backup-id the account should have previously committed to
|
||||
final byte[] committedBytes = account.getBackupCredentialRequest();
|
||||
if (committedBytes == null) {
|
||||
throw Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException();
|
||||
}
|
||||
final byte[] committedBytes = account.getBackupCredentialRequest(credentialType)
|
||||
.orElseThrow(() -> Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException());
|
||||
|
||||
try {
|
||||
// create a credential for every day in the requested period
|
||||
final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);
|
||||
return CompletableFuture.completedFuture(Stream
|
||||
.iterate(redemptionStart, curr -> curr.plus(Duration.ofDays(1)))
|
||||
.takeWhile(redemptionTime -> !redemptionTime.isAfter(redemptionEnd))
|
||||
.iterate(redemptionStart, redemptionTime -> !redemptionTime.isAfter(redemptionEnd), curr -> curr.plus(Duration.ofDays(1)))
|
||||
.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(configuredBackupLevel);
|
||||
return new Credential(
|
||||
credentialReq.issueCredential(redemptionTime, backupLevel, serverSecretParams),
|
||||
credentialReq.issueCredential(redemptionTime, backupLevel, credentialType, serverSecretParams),
|
||||
redemptionTime);
|
||||
})
|
||||
.toList());
|
||||
@@ -210,12 +237,18 @@ public class BackupAuthManager {
|
||||
|
||||
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
|
||||
|
||||
if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.MEDIA) {
|
||||
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 -> {
|
||||
@@ -224,13 +257,28 @@ public class BackupAuthManager {
|
||||
.withDescription("receipt serial is already redeemed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return accountsManager.updateAsync(account, a -> {
|
||||
final Account.BackupVoucher newPayment = new Account.BackupVoucher(receiptLevel, receiptExpiration);
|
||||
final Account.BackupVoucher existingPayment = a.getBackupVoucher();
|
||||
account.setBackupVoucher(merge(existingPayment, newPayment));
|
||||
});
|
||||
})
|
||||
.thenRun(Util.NOOP);
|
||||
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,
|
||||
@@ -254,8 +302,12 @@ public class BackupAuthManager {
|
||||
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 && clock.instant().isAfter(account.getBackupVoucher().expiration());
|
||||
return account.getBackupVoucher() != null && !hasActiveVoucher(account);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,10 +333,10 @@ public class BackupAuthManager {
|
||||
*/
|
||||
private Optional<BackupLevel> configuredBackupLevel(final Account account) {
|
||||
if (inExperiment(BACKUP_MEDIA_EXPERIMENT_NAME, account)) {
|
||||
return Optional.of(BackupLevel.MEDIA);
|
||||
return Optional.of(BackupLevel.PAID);
|
||||
}
|
||||
if (inExperiment(BACKUP_EXPERIMENT_NAME, account)) {
|
||||
return Optional.of(BackupLevel.MESSAGES);
|
||||
return Optional.of(BackupLevel.FREE);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ package org.whispersystems.textsecuregcm.backup;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.util.DataSize;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
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;
|
||||
@@ -21,35 +24,39 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
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.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
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;
|
||||
|
||||
public class BackupManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);
|
||||
|
||||
static final String MESSAGE_BACKUP_NAME = "messageBackup";
|
||||
public static final long MAX_TOTAL_BACKUP_MEDIA_BYTES = DataSize.gibibytes(100).toBytes();
|
||||
static final long MAX_MEDIA_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();
|
||||
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();
|
||||
|
||||
// If the last media usage recalculation is over MAX_QUOTA_STALENESS, force a recalculation before quota enforcement.
|
||||
static final Duration MAX_QUOTA_STALENESS = Duration.ofDays(1);
|
||||
@@ -60,6 +67,10 @@ public class BackupManager {
|
||||
// How many cdn object copy requests can be outstanding at a time per batch copy-to-backup operation
|
||||
private static final int COPY_CONCURRENCY = 10;
|
||||
|
||||
// How often we should persist the current usage
|
||||
@VisibleForTesting
|
||||
static int USAGE_CHECKPOINT_COUNT = 10;
|
||||
|
||||
|
||||
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,
|
||||
@@ -74,6 +85,8 @@ public class BackupManager {
|
||||
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;
|
||||
@@ -120,8 +133,10 @@ public class BackupManager {
|
||||
// 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 BackupLevel backupLevel = verifyPresentation(presentation).verifySignature(signature, publicKey);
|
||||
return backupsDb.setPublicKey(presentation.getBackupId(), backupLevel, publicKey)
|
||||
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),
|
||||
@@ -144,7 +159,8 @@ public class BackupManager {
|
||||
*/
|
||||
public CompletableFuture<BackupUploadDescriptor> createMessageBackupUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
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
|
||||
@@ -154,7 +170,8 @@ public class BackupManager {
|
||||
|
||||
public CompletableFuture<BackupUploadDescriptor> createTemporaryAttachmentUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MEDIA);
|
||||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
return rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT)
|
||||
.validateAsync(rateLimitKey(backupUser)).thenApply(ignored -> {
|
||||
@@ -172,7 +189,7 @@ public class BackupManager {
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
*/
|
||||
public CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
// update message backup TTL
|
||||
return backupsDb.ttlRefresh(backupUser);
|
||||
}
|
||||
@@ -187,7 +204,7 @@ public class BackupManager {
|
||||
* @return Information about the existing backup
|
||||
*/
|
||||
public CompletableFuture<BackupInfo> backupInfo(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return backupsDb.describeBackup(backupUser)
|
||||
.thenApply(backupDescription -> new BackupInfo(
|
||||
backupDescription.cdn(),
|
||||
@@ -210,31 +227,42 @@ public class BackupManager {
|
||||
* detailing why the object could not be copied.
|
||||
*/
|
||||
public Flux<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, List<CopyParameters> toCopy) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MEDIA);
|
||||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
return Mono
|
||||
// Figure out how many objects we're allowed to copy, updating the quota usage for the amount we are allowed
|
||||
.fromFuture(enforceQuota(backupUser, toCopy))
|
||||
|
||||
// Copy the ones we have enough quota to hold
|
||||
return Mono.fromFuture(() -> allowedCopies(backupUser, toCopy))
|
||||
.flatMapMany(quotaResult -> Flux.concat(
|
||||
|
||||
// These fit in our remaining quota, so perform the copy. If the copy fails, our estimated quota usage may not
|
||||
// be exact since we already updated our usage. We make a best-effort attempt to undo the usage update if we
|
||||
// know that the copied failed for sure though.
|
||||
Flux.fromIterable(quotaResult.requestsToCopy()).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);
|
||||
}),
|
||||
COPY_CONCURRENCY),
|
||||
// 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(USAGE_CHECKPOINT_COUNT)
|
||||
.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);
|
||||
}),
|
||||
COPY_CONCURRENCY, 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))));
|
||||
.map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null))
|
||||
));
|
||||
}
|
||||
|
||||
private Mono<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, final CopyParameters copyParameters) {
|
||||
@@ -260,15 +288,14 @@ public class BackupManager {
|
||||
private record QuotaResult(List<CopyParameters> requestsToCopy, List<CopyParameters> requestsToReject) {}
|
||||
|
||||
/**
|
||||
* Determine which copy requests can be performed with the user's remaining quota and update the used quota. If a copy
|
||||
* request subsequently fails, the caller should attempt to restore the quota for the failed copy.
|
||||
* 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 update
|
||||
* @param backupUser The user quota to check against
|
||||
* @param toCopy The proposed copy requests
|
||||
* @return QuotaResult indicating which requests fit into the remaining quota and which requests should be rejected
|
||||
* with {@link CopyResult.Outcome#OUT_OF_QUOTA}
|
||||
* @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> enforceQuota(
|
||||
private CompletableFuture<QuotaResult> allowedCopies(
|
||||
final AuthenticatedBackupUser backupUser,
|
||||
final List<CopyParameters> toCopy) {
|
||||
final long totalBytesAdded = toCopy.stream()
|
||||
@@ -298,30 +325,34 @@ public class BackupManager {
|
||||
.thenApply(ignored -> usage))
|
||||
.whenComplete((newUsage, throwable) -> {
|
||||
boolean usageChanged = throwable == null && !newUsage.equals(info.usageInfo());
|
||||
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, "usageChanged", String.valueOf(usageChanged))
|
||||
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of("usageChanged", String.valueOf(usageChanged))))
|
||||
.increment();
|
||||
})
|
||||
.thenApply(newUsage -> MAX_TOTAL_BACKUP_MEDIA_BYTES - newUsage.bytesUsed());
|
||||
})
|
||||
.thenCompose(remainingQuota -> {
|
||||
.thenApply(remainingQuota -> {
|
||||
// Figure out how many of the requested objects fit in the remaining quota
|
||||
final int index = indexWhereTotalExceeds(toCopy, CopyParameters::destinationObjectSize,
|
||||
remainingQuota);
|
||||
final QuotaResult result = new QuotaResult(toCopy.subList(0, index),
|
||||
toCopy.subList(index, toCopy.size()));
|
||||
if (index == 0) {
|
||||
// Skip the usage update if we're not able to write anything
|
||||
return CompletableFuture.completedFuture(result);
|
||||
}
|
||||
|
||||
// Update the usage
|
||||
final long quotaToConsume = result.requestsToCopy.stream()
|
||||
.mapToLong(CopyParameters::destinationObjectSize)
|
||||
.sum();
|
||||
return backupsDb.trackMedia(backupUser, index, quotaToConsume).thenApply(ignored -> result);
|
||||
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
|
||||
*/
|
||||
@@ -349,7 +380,7 @@ public class BackupManager {
|
||||
* @return A map of headers to include with CDN requests
|
||||
*/
|
||||
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser, final int cdnNumber) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
if (cdnNumber != 3) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("unknown cdn").asRuntimeException();
|
||||
}
|
||||
@@ -377,7 +408,7 @@ public class BackupManager {
|
||||
final AuthenticatedBackupUser backupUser,
|
||||
final Optional<String> cursor,
|
||||
final int limit) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit)
|
||||
.thenApply(result ->
|
||||
new ListMediaResult(
|
||||
@@ -395,7 +426,7 @@ public class BackupManager {
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> deleteEntireBackup(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return backupsDb
|
||||
// Try to swap out the backupDir for the user
|
||||
.scheduleBackupDeletion(backupUser)
|
||||
@@ -408,7 +439,8 @@ public class BackupManager {
|
||||
|
||||
public Flux<StorageDescriptor> deleteMedia(final AuthenticatedBackupUser backupUser,
|
||||
final List<StorageDescriptor> storageDescriptors) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
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())) {
|
||||
@@ -419,45 +451,79 @@ public class BackupManager {
|
||||
|
||||
return Flux.usingWhen(
|
||||
|
||||
// Gather usage updates into the UsageBatcher to apply during the cleanup operation
|
||||
// Gather usage updates into the UsageBatcher so we don't have to update our backup record on every delete
|
||||
Mono.just(new UsageBatcher()),
|
||||
|
||||
// Deletes the objects, returning their former location. Tracks bytes removed so the quota can be updated on
|
||||
// completion
|
||||
batcher -> Flux.fromIterable(storageDescriptors)
|
||||
.flatMapSequential(sd -> Mono
|
||||
// Delete the object
|
||||
.fromCompletionStage(remoteStorageManager.delete(cdnMediaPath(backupUser, sd.key())))
|
||||
// Track how much the remote storage manager indicated was deleted as part of the operation
|
||||
.doOnNext(deletedBytes -> batcher.update(-deletedBytes))
|
||||
.thenReturn(sd), DELETION_CONCURRENCY),
|
||||
|
||||
// On cleanup, update the quota using whatever updates were accumulated in the batcher
|
||||
batcher ->
|
||||
Mono.fromFuture(backupsDb.trackMedia(backupUser, batcher.countDelta.get(), batcher.usageDelta.get())));
|
||||
// Delete the objects, allowing DELETION_CONCURRENCY operations out at a time
|
||||
.flatMapSequential(
|
||||
sd -> Mono.fromCompletionStage(remoteStorageManager.delete(cdnMediaPath(backupUser, sd.key()))),
|
||||
DELETION_CONCURRENCY)
|
||||
.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
|
||||
* Track pending media usage updates. Not thread safe!
|
||||
*/
|
||||
private static class UsageBatcher {
|
||||
|
||||
AtomicLong countDelta = new AtomicLong();
|
||||
AtomicLong usageDelta = new AtomicLong();
|
||||
private long runningCountDelta = 0;
|
||||
private long runningBytesDelta = 0;
|
||||
|
||||
record UsageUpdate(long countDelta, long bytesDelta) {}
|
||||
|
||||
/**
|
||||
* Stage a usage update that will be applied later
|
||||
* 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
|
||||
*/
|
||||
void update(long bytesDelta) {
|
||||
if (bytesDelta < 0) {
|
||||
countDelta.decrementAndGet();
|
||||
} else if (bytesDelta > 0) {
|
||||
countDelta.incrementAndGet();
|
||||
}
|
||||
usageDelta.addAndGet(bytesDelta);
|
||||
boolean update(long bytesDelta) {
|
||||
this.runningCountDelta += Long.signum(bytesDelta);
|
||||
this.runningBytesDelta += bytesDelta;
|
||||
return Math.abs(runningCountDelta) >= USAGE_CHECKPOINT_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +544,8 @@ public class BackupManager {
|
||||
*/
|
||||
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature) {
|
||||
final byte[] signature,
|
||||
final String userAgentString) {
|
||||
final PresentationSignatureVerifier signatureVerifier = verifyPresentation(presentation);
|
||||
return backupsDb
|
||||
.retrieveAuthenticationData(presentation.getBackupId())
|
||||
@@ -492,10 +559,24 @@ public class BackupManager {
|
||||
// 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());
|
||||
|
||||
UserAgent userAgent;
|
||||
try {
|
||||
userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
|
||||
} catch (UnrecognizedUserAgentException e) {
|
||||
userAgent = null;
|
||||
}
|
||||
|
||||
return new AuthenticatedBackupUser(
|
||||
presentation.getBackupId(),
|
||||
signatureVerifier.verifySignature(signature, authenticationData.publicKey()),
|
||||
authenticationData.backupDir(), authenticationData.mediaDir());
|
||||
credentialTypeAndBackupLevel.first(),
|
||||
credentialTypeAndBackupLevel.second(),
|
||||
authenticationData.backupDir(),
|
||||
authenticationData.mediaDir(),
|
||||
userAgent);
|
||||
})
|
||||
.thenApply(result -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
|
||||
@@ -579,7 +660,7 @@ public class BackupManager {
|
||||
|
||||
interface PresentationSignatureVerifier {
|
||||
|
||||
BackupLevel verifySignature(byte[] signature, ECPublicKey publicKey);
|
||||
Pair<BackupCredentialType, BackupLevel> verifySignature(byte[] signature, ECPublicKey publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -611,7 +692,7 @@ public class BackupManager {
|
||||
.withDescription("backup auth credential presentation signature verification failed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return presentation.getBackupLevel();
|
||||
return new Pair<>(presentation.getType(), presentation.getBackupLevel());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -622,15 +703,41 @@ public class BackupManager {
|
||||
* @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}
|
||||
*/
|
||||
private static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
|
||||
@VisibleForTesting
|
||||
static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
|
||||
if (backupUser.backupLevel().compareTo(backupLevel) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
|
||||
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);
|
||||
@@ -644,8 +751,12 @@ public class BackupManager {
|
||||
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 "%s/%s/".formatted(backupUser.backupDir(), backupUser.mediaDir());
|
||||
return cdnMediaDirectory(backupUser.backupDir(), backupUser.mediaDir());
|
||||
}
|
||||
|
||||
private static String cdnMediaPath(final AuthenticatedBackupUser backupUser, final byte[] mediaId) {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -21,12 +22,18 @@ 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;
|
||||
@@ -38,6 +45,7 @@ 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;
|
||||
@@ -79,6 +87,10 @@ public class BackupsDb {
|
||||
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
private static final String NUM_OBJECTS_SUMMARY_NAME = MetricsUtil.name(BackupsDb.class, "numObjects");
|
||||
private static final String BYTES_USED_SUMMARY_NAME = MetricsUtil.name(BackupsDb.class, "bytesUsed");
|
||||
private static final String BACKUPS_COUNTER_NAME = MetricsUtil.name(BackupsDb.class, "backups");
|
||||
|
||||
// The backups table
|
||||
|
||||
// B: 16 bytes that identifies the backup
|
||||
@@ -87,7 +99,7 @@ public class BackupsDb {
|
||||
// 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.MEDIA, and must be periodically updated to avoid garbage collection of media objects.
|
||||
// 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
|
||||
@@ -217,12 +229,10 @@ public class BackupsDb {
|
||||
*/
|
||||
CompletableFuture<Void> trackMedia(final AuthenticatedBackupUser backupUser, final long mediaCountDelta,
|
||||
final long mediaBytesDelta) {
|
||||
final Instant now = clock.instant();
|
||||
return dynamoClient
|
||||
.updateItem(
|
||||
// Update the media quota and TTL
|
||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(now)
|
||||
.incrementMediaBytes(mediaBytesDelta)
|
||||
.incrementMediaCount(mediaCountDelta)
|
||||
.updateItemBuilder()
|
||||
@@ -237,12 +247,15 @@ public class BackupsDb {
|
||||
* @param backupUser an already authorized backup user
|
||||
*/
|
||||
CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
// update message backup TTL
|
||||
return dynamoClient.updateItem(UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(clock)
|
||||
.setRefreshTimes(today)
|
||||
.updateItemBuilder()
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
.thenAccept(updateItemResponse ->
|
||||
updateMetricsAfterRefresh(backupUser, today, updateItemResponse.attributes()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,21 +264,56 @@ public class BackupsDb {
|
||||
* @param backupUser an already authorized backup user
|
||||
*/
|
||||
CompletableFuture<Void> 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(clock)
|
||||
.setRefreshTimes(today)
|
||||
.setCdn(BACKUP_CDN)
|
||||
.updateItemBuilder()
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
.thenAccept(updateItemResponse ->
|
||||
updateMetricsAfterRefresh(backupUser, today, updateItemResponse.attributes()));
|
||||
}
|
||||
|
||||
private void updateMetricsAfterRefresh(final AuthenticatedBackupUser backupUser, final Instant today, final Map<String, AttributeValue> item) {
|
||||
final Instant previousRefreshTime = Instant.ofEpochSecond(
|
||||
AttributeValues.getLong(item, ATTR_LAST_REFRESH, 0L));
|
||||
// Only publish a metric update once per day
|
||||
if (previousRefreshTime.isBefore(today)) {
|
||||
final long mediaCount = AttributeValues.getLong(item, ATTR_MEDIA_COUNT, 0L);
|
||||
final long bytesUsed = AttributeValues.getLong(item, ATTR_MEDIA_BYTES_USED, 0L);
|
||||
final Tags tags = Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of("tier", backupUser.backupLevel().name()));
|
||||
|
||||
DistributionSummary.builder(NUM_OBJECTS_SUMMARY_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(mediaCount);
|
||||
DistributionSummary.builder(BYTES_USED_SUMMARY_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(bytesUsed);
|
||||
|
||||
// Report that the backup is out of quota if it cannot store a max size media object
|
||||
final boolean quotaExhausted = bytesUsed >=
|
||||
(BackupManager.MAX_TOTAL_BACKUP_MEDIA_BYTES - BackupManager.MAX_MEDIA_OBJECT_SIZE);
|
||||
|
||||
Metrics.counter(BACKUPS_COUNTER_NAME,
|
||||
tags.and("quotaExhausted", String.valueOf(quotaExhausted)))
|
||||
.increment();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that we couldn't schedule a deletion because one was already scheduled. The caller may want to delete the
|
||||
* objects directly.
|
||||
*/
|
||||
class PendingDeletionException extends IOException {}
|
||||
static class PendingDeletionException extends IOException {}
|
||||
|
||||
/**
|
||||
* Attempt to mark a backup as expired and swap in a new empty backupDir for the user.
|
||||
@@ -285,7 +333,7 @@ public class BackupsDb {
|
||||
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.MEDIA, hashedBackupId)
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.clearMediaUsage(clock)
|
||||
.expireDirectoryNames(secureRandom, ExpiredBackup.ExpirationType.ALL)
|
||||
.setRefreshTimes(Instant.ofEpochSecond(0))
|
||||
@@ -300,7 +348,7 @@ public class BackupsDb {
|
||||
// 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.MEDIA, hashedBackupId)
|
||||
dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.setRefreshTimes(Instant.ofEpochSecond(0))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
@@ -365,8 +413,16 @@ public class BackupsDb {
|
||||
}
|
||||
|
||||
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.forUser(backupTableName, backupUser)
|
||||
updateBuilder
|
||||
.addSetExpression("#mediaBytesUsed = :mediaBytesUsed",
|
||||
Map.entry("#mediaBytesUsed", ATTR_MEDIA_BYTES_USED),
|
||||
Map.entry(":mediaBytesUsed", AttributeValues.n(usageInfo.bytesUsed())))
|
||||
@@ -399,7 +455,7 @@ public class BackupsDb {
|
||||
}
|
||||
|
||||
// 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.MEDIA, expiredBackup.hashedBackupId())
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, expiredBackup.hashedBackupId())
|
||||
.clearMediaUsage(clock)
|
||||
.expireDirectoryNames(secureRandom, expiredBackup.expirationType())
|
||||
.addRemoveExpression(Map.entry("#mediaRefresh", ATTR_LAST_MEDIA_REFRESH))
|
||||
@@ -433,7 +489,7 @@ public class BackupsDb {
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
} else {
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.MEDIA, hashedBackupId)
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.addRemoveExpression(Map.entry("#expiredPrefixes", ATTR_EXPIRED_PREFIX))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
@@ -459,13 +515,18 @@ public class BackupsDb {
|
||||
"#refresh", ATTR_LAST_REFRESH,
|
||||
"#mediaRefresh", ATTR_LAST_MEDIA_REFRESH,
|
||||
"#bytesUsed", ATTR_MEDIA_BYTES_USED,
|
||||
"#numObjects", ATTR_MEDIA_COUNT))
|
||||
.projectionExpression("#backupIdHash, #refresh, #mediaRefresh, #bytesUsed, #numObjects")
|
||||
"#numObjects", ATTR_MEDIA_COUNT,
|
||||
"#backupDir", ATTR_BACKUP_DIR,
|
||||
"#mediaDir", ATTR_MEDIA_DIR))
|
||||
.projectionExpression("#backupIdHash, #refresh, #mediaRefresh, #bytesUsed, #numObjects, #backupDir, #mediaDir")
|
||||
.build())
|
||||
.items())
|
||||
.sequential()
|
||||
.filter(item -> item.containsKey(KEY_BACKUP_ID_HASH))
|
||||
.map(item -> 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),
|
||||
@@ -707,22 +768,25 @@ public class BackupsDb {
|
||||
};
|
||||
}
|
||||
|
||||
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 Clock clock) {
|
||||
return this.setRefreshTimes(clock.instant());
|
||||
}
|
||||
|
||||
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.MEDIA) >= 0) {
|
||||
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),
|
||||
|
||||
@@ -3,6 +3,8 @@ 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;
|
||||
@@ -19,11 +21,8 @@ import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -55,6 +54,8 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
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,
|
||||
@@ -112,6 +113,10 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
.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()) {
|
||||
@@ -129,7 +134,7 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
* Serialized copy request for cdn3 storage manager
|
||||
*/
|
||||
record Cdn3CopyRequest(
|
||||
String encryptionKey, String hmacKey, String iv,
|
||||
String encryptionKey, String hmacKey,
|
||||
SourceDescriptor source, int expectedSourceLength,
|
||||
String dst) {
|
||||
|
||||
@@ -137,7 +142,6 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
String dst) {
|
||||
this(Base64.getEncoder().encodeToString(parameters.aesEncryptionKey().getEncoded()),
|
||||
Base64.getEncoder().encodeToString(parameters.hmacSHA256Key().getEncoded()),
|
||||
Base64.getEncoder().encodeToString(parameters.iv().getIV()),
|
||||
source, expectedSourceLength, dst);
|
||||
}
|
||||
|
||||
@@ -261,6 +265,7 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
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)
|
||||
@@ -273,11 +278,17 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
try {
|
||||
return parseDeleteResponse(response);
|
||||
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 {
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public record MediaEncryptionParameters(
|
||||
SecretKeySpec aesEncryptionKey,
|
||||
SecretKeySpec hmacSHA256Key,
|
||||
IvParameterSpec iv) {
|
||||
SecretKeySpec hmacSHA256Key) {
|
||||
|
||||
public MediaEncryptionParameters(byte[] encryptionKey, byte[] macKey, byte[] iv) {
|
||||
public MediaEncryptionParameters(byte[] encryptionKey, byte[] macKey) {
|
||||
this(
|
||||
new SecretKeySpec(encryptionKey, "AES"),
|
||||
new SecretKeySpec(macKey, "HmacSHA256"),
|
||||
new IvParameterSpec(iv));
|
||||
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 this.iv().getIV().length + (numBlocks * 16) + 32;
|
||||
return 16 + (numBlocks * 16) + 32;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,19 @@ import java.time.Instant;
|
||||
/**
|
||||
* Attributes stored in the backups table for a single backup id
|
||||
*
|
||||
* @param hashedBackupId The hashed backup-id of this entry
|
||||
* @param backupDir The cdn backupDir of this entry
|
||||
* @param mediaDir The cdn mediaDir (within the backupDir) of this entry
|
||||
* @param lastRefresh The last time the record was updated with a messages or media tier credential
|
||||
* @param lastMediaRefresh The last time the record was updated with a media tier credential
|
||||
* @param bytesUsed The number of media bytes used by the backup
|
||||
* @param numObjects The number of media objects used byt the backup
|
||||
*/
|
||||
public record StoredBackupAttributes(
|
||||
Instant lastRefresh, Instant lastMediaRefresh,
|
||||
long bytesUsed, long numObjects) {}
|
||||
byte[] hashedBackupId,
|
||||
String backupDir,
|
||||
String mediaDir,
|
||||
Instant lastRefresh,
|
||||
Instant lastMediaRefresh,
|
||||
long bytesUsed,
|
||||
long numObjects) {}
|
||||
|
||||
@@ -76,22 +76,22 @@ public class ConfiguredProfileBadgeConverter implements ProfileBadgeConverter, B
|
||||
final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME,
|
||||
acceptableLanguages);
|
||||
List<Badge> badges = accountBadges.stream()
|
||||
.filter(accountBadge -> (isSelf || accountBadge.isVisible())
|
||||
&& now.isBefore(accountBadge.getExpiration())
|
||||
&& knownBadges.containsKey(accountBadge.getId()))
|
||||
.filter(accountBadge -> (isSelf || accountBadge.visible())
|
||||
&& now.isBefore(accountBadge.expiration())
|
||||
&& knownBadges.containsKey(accountBadge.id()))
|
||||
.map(accountBadge -> {
|
||||
BadgeConfiguration configuration = knownBadges.get(accountBadge.getId());
|
||||
BadgeConfiguration configuration = knownBadges.get(accountBadge.id());
|
||||
return newBadge(
|
||||
isSelf,
|
||||
accountBadge.getId(),
|
||||
accountBadge.id(),
|
||||
configuration.getCategory(),
|
||||
resourceBundle.getString(accountBadge.getId() + "_name"),
|
||||
resourceBundle.getString(accountBadge.getId() + "_description"),
|
||||
resourceBundle.getString(accountBadge.id() + "_name"),
|
||||
resourceBundle.getString(accountBadge.id() + "_description"),
|
||||
configuration.getSprites(),
|
||||
configuration.getSvg(),
|
||||
configuration.getSvgs(),
|
||||
accountBadge.getExpiration(),
|
||||
accountBadge.isVisible());
|
||||
accountBadge.expiration(),
|
||||
accountBadge.visible());
|
||||
})
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
badges.addAll(badgeIdsEnabledForAll.stream().filter(knownBadges::containsKey).map(id -> {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.badges;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.ResourceBundle;
|
||||
import javax.annotation.Nonnull;
|
||||
import org.signal.i18n.HeaderControlledResourceBundleLookup;
|
||||
|
||||
public class ResourceBundleLevelTranslator implements LevelTranslator {
|
||||
|
||||
private static final String BASE_NAME = "org.signal.subscriptions.Subscriptions";
|
||||
|
||||
private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup;
|
||||
|
||||
public ResourceBundleLevelTranslator(
|
||||
@Nonnull final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) {
|
||||
this.headerControlledResourceBundleLookup = Objects.requireNonNull(headerControlledResourceBundleLookup);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String translate(final List<Locale> acceptableLanguages, final String badgeId) {
|
||||
final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME,
|
||||
acceptableLanguages);
|
||||
return resourceBundle.getString(badgeId);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record CallDnsRecords(
|
||||
@NotNull
|
||||
Map<String, List<InetAddress>> aByRegion,
|
||||
@NotNull
|
||||
Map<String, List<InetAddress>> aaaaByRegion
|
||||
) {
|
||||
public String getSummary() {
|
||||
int numARecords = aByRegion.values().stream().mapToInt(List::size).sum();
|
||||
int numAAAARecords = aaaaByRegion.values().stream().mapToInt(List::size).sum();
|
||||
return String.format(
|
||||
"(A records, %s regions, %s records), (AAAA records, %s regions, %s records)",
|
||||
aByRegion.size(),
|
||||
numARecords,
|
||||
aaaaByRegion.size(),
|
||||
numAAAARecords
|
||||
);
|
||||
}
|
||||
|
||||
public static CallDnsRecords empty() {
|
||||
return new CallDnsRecords(Map.of(), Map.of());
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import com.fasterxml.jackson.core.StreamReadFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
|
||||
public class CallDnsRecordsManager implements Supplier<CallDnsRecords>, Managed {
|
||||
|
||||
private final S3ObjectMonitor objectMonitor;
|
||||
|
||||
private final AtomicReference<CallDnsRecords> callDnsRecords = new AtomicReference<>();
|
||||
|
||||
private final Timer refreshTimer;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CallDnsRecordsManager.class);
|
||||
|
||||
private static final ObjectMapper objectMapper = JsonMapper.builder()
|
||||
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
|
||||
.build();
|
||||
|
||||
public CallDnsRecordsManager(final ScheduledExecutorService executorService,
|
||||
final AwsCredentialsProvider awsCredentialsProvider, final S3ObjectMonitorFactory configuration) {
|
||||
|
||||
this.objectMonitor = configuration.build(awsCredentialsProvider, executorService);
|
||||
this.callDnsRecords.set(CallDnsRecords.empty());
|
||||
this.refreshTimer = Metrics.timer(MetricsUtil.name(CallDnsRecordsManager.class, "refresh"));
|
||||
}
|
||||
|
||||
private void handleDatabaseChanged(final InputStream inputStream) {
|
||||
refreshTimer.record(() -> {
|
||||
try (final InputStream bufferedInputStream = new BufferedInputStream(inputStream)) {
|
||||
final CallDnsRecords newRecords = parseRecords(bufferedInputStream);
|
||||
final CallDnsRecords oldRecords = callDnsRecords.getAndSet(newRecords);
|
||||
log.info("Replaced dns records, old summary=[{}], new summary=[{}]", oldRecords != null ? oldRecords.getSummary() : "null", newRecords);
|
||||
} catch (final IOException e) {
|
||||
log.error("Failed to load Call DNS Records");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static CallDnsRecords parseRecords(InputStream inputStream) throws IOException {
|
||||
return objectMapper.readValue(inputStream, CallDnsRecords.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws Exception {
|
||||
objectMonitor.start(this::handleDatabaseChanged);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
objectMonitor.stop();
|
||||
callDnsRecords.getAndSet(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallDnsRecords get() {
|
||||
return this.callDnsRecords.get();
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.math.BigInteger;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class CallRoutingTable {
|
||||
private final TreeMap<Integer, Map<Integer, List<String>>> ipv4Map;
|
||||
private final TreeMap<Integer, Map<BigInteger, List<String>>> ipv6Map;
|
||||
private final Map<GeoKey, List<String>> geoToDatacenter;
|
||||
|
||||
public CallRoutingTable(
|
||||
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4SubnetToDatacenter,
|
||||
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6SubnetToDatacenter,
|
||||
Map<GeoKey, List<String>> geoToDatacenter
|
||||
) {
|
||||
this.ipv4Map = new TreeMap<>();
|
||||
for (Map.Entry<CidrBlock.IpV4CidrBlock, List<String>> t : ipv4SubnetToDatacenter.entrySet()) {
|
||||
if (!this.ipv4Map.containsKey(t.getKey().cidrBlockSize())) {
|
||||
this.ipv4Map.put(t.getKey().cidrBlockSize(), new HashMap<>());
|
||||
}
|
||||
this.ipv4Map
|
||||
.get(t.getKey().cidrBlockSize())
|
||||
.put(t.getKey().subnet(), t.getValue());
|
||||
}
|
||||
|
||||
this.ipv6Map = new TreeMap<>();
|
||||
for (Map.Entry<CidrBlock.IpV6CidrBlock, List<String>> t : ipv6SubnetToDatacenter.entrySet()) {
|
||||
if (!this.ipv6Map.containsKey(t.getKey().cidrBlockSize())) {
|
||||
this.ipv6Map.put(t.getKey().cidrBlockSize(), new HashMap<>());
|
||||
}
|
||||
this.ipv6Map
|
||||
.get(t.getKey().cidrBlockSize())
|
||||
.put(t.getKey().subnet(), t.getValue());
|
||||
}
|
||||
|
||||
this.geoToDatacenter = geoToDatacenter;
|
||||
}
|
||||
|
||||
public static CallRoutingTable empty() {
|
||||
return new CallRoutingTable(Map.of(), Map.of(), Map.of());
|
||||
}
|
||||
|
||||
public enum Protocol {
|
||||
v4,
|
||||
v6
|
||||
}
|
||||
|
||||
public record GeoKey(
|
||||
@NotBlank String continent,
|
||||
@NotBlank String country,
|
||||
@NotNull Optional<String> subdivision,
|
||||
@NotBlank Protocol protocol
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns ordered list of fastest datacenters based on IP & Geo info. Prioritize the results based on subnet.
|
||||
* Returns at most three, 2 by subnet and 1 by geo. Takes more from either bucket to hit 3.
|
||||
*/
|
||||
public List<String> getDatacentersFor(
|
||||
InetAddress address,
|
||||
String continent,
|
||||
String country,
|
||||
Optional<String> subdivision
|
||||
) {
|
||||
final int NUM_DATACENTERS = 3;
|
||||
|
||||
if(this.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<String> dcsBySubnet = getDatacentersBySubnet(address);
|
||||
List<String> dcsByGeo = getDatacentersByGeo(continent, country, subdivision).stream()
|
||||
.limit(NUM_DATACENTERS)
|
||||
.filter(dc ->
|
||||
(dcsBySubnet.isEmpty() || !dc.equals(dcsBySubnet.getFirst()))
|
||||
&& (dcsBySubnet.size() < 2 || !dc.equals(dcsBySubnet.get(1)))
|
||||
).toList();
|
||||
|
||||
return Stream.concat(
|
||||
dcsBySubnet.stream().limit(dcsByGeo.isEmpty() ? NUM_DATACENTERS : NUM_DATACENTERS - 1),
|
||||
dcsByGeo.stream())
|
||||
.limit(NUM_DATACENTERS)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return this.ipv4Map.isEmpty() && this.ipv6Map.isEmpty() && this.geoToDatacenter.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ordered list of fastest datacenters based on ip info. Prioritizes V4 connections.
|
||||
*/
|
||||
public List<String> getDatacentersBySubnet(InetAddress address) throws IllegalArgumentException {
|
||||
if(address instanceof Inet4Address) {
|
||||
for(Map.Entry<Integer, Map<Integer, List<String>>> t: this.ipv4Map.descendingMap().entrySet()) {
|
||||
int maskedIp = CidrBlock.IpV4CidrBlock.maskToSize((Inet4Address) address, t.getKey());
|
||||
if(t.getValue().containsKey(maskedIp)) {
|
||||
return t.getValue().get(maskedIp);
|
||||
}
|
||||
}
|
||||
} else if (address instanceof Inet6Address) {
|
||||
for(Map.Entry<Integer, Map<BigInteger, List<String>>> t: this.ipv6Map.descendingMap().entrySet()) {
|
||||
BigInteger maskedIp = CidrBlock.IpV6CidrBlock.maskToSize((Inet6Address) address, t.getKey());
|
||||
if(t.getValue().containsKey(maskedIp)) {
|
||||
return t.getValue().get(maskedIp);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("Expected either an Inet4Address or Inet6Address");
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ordered list of fastest datacenters based on geo info. Attempts to match based on subdivision, falls back
|
||||
* to country based lookup. Does not attempt to look for nearby subdivisions. Prioritizes V4 connections.
|
||||
*/
|
||||
public List<String> getDatacentersByGeo(
|
||||
String continent,
|
||||
String country,
|
||||
Optional<String> subdivision
|
||||
) {
|
||||
GeoKey v4Key = new GeoKey(continent, country, subdivision, Protocol.v4);
|
||||
List<String> v4Options = this.geoToDatacenter.getOrDefault(v4Key, Collections.emptyList());
|
||||
List<String> v4OptionsBackup = v4Options.isEmpty() && subdivision.isPresent() ?
|
||||
this.geoToDatacenter.getOrDefault(
|
||||
new GeoKey(continent, country, Optional.empty(), Protocol.v4),
|
||||
Collections.emptyList())
|
||||
: Collections.emptyList();
|
||||
|
||||
GeoKey v6Key = new GeoKey(continent, country, subdivision, Protocol.v6);
|
||||
List<String> v6Options = this.geoToDatacenter.getOrDefault(v6Key, Collections.emptyList());
|
||||
List<String> v6OptionsBackup = v6Options.isEmpty() && subdivision.isPresent() ?
|
||||
this.geoToDatacenter.getOrDefault(
|
||||
new GeoKey(continent, country, Optional.empty(), Protocol.v6),
|
||||
Collections.emptyList())
|
||||
: Collections.emptyList();
|
||||
|
||||
return Stream.of(
|
||||
v4Options.stream(),
|
||||
v6Options.stream(),
|
||||
v4OptionsBackup.stream(),
|
||||
v6OptionsBackup.stream()
|
||||
)
|
||||
.flatMap(Function.identity())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String toSummaryString() {
|
||||
return String.format(
|
||||
"[Ipv4Table=%s rows, Ipv6Table=%s rows, GeoTable=%s rows]",
|
||||
ipv4Map.size(),
|
||||
ipv6Map.size(),
|
||||
geoToDatacenter.size()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
CallRoutingTable that = (CallRoutingTable) o;
|
||||
return Objects.equals(ipv4Map, that.ipv4Map) && Objects.equals(ipv6Map, that.ipv6Map) && Objects.equals(
|
||||
geoToDatacenter, that.geoToDatacenter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(ipv4Map, ipv6Map, geoToDatacenter);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
|
||||
public class CallRoutingTableManager implements Supplier<CallRoutingTable>, Managed {
|
||||
|
||||
private final S3ObjectMonitor objectMonitor;
|
||||
|
||||
private final AtomicReference<CallRoutingTable> routingTable = new AtomicReference<>();
|
||||
|
||||
private final String tableTag;
|
||||
|
||||
private final Timer refreshTimer;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CallRoutingTableManager.class);
|
||||
|
||||
public CallRoutingTableManager(final ScheduledExecutorService executorService,
|
||||
final AwsCredentialsProvider awsCredentialsProvider, final S3ObjectMonitorFactory configuration,
|
||||
final String tableTag) {
|
||||
|
||||
this.objectMonitor = configuration.build(awsCredentialsProvider, executorService);
|
||||
this.tableTag = tableTag;
|
||||
this.routingTable.set(CallRoutingTable.empty());
|
||||
this.refreshTimer = Metrics.timer(MetricsUtil.name(CallRoutingTableManager.class, tableTag));
|
||||
}
|
||||
|
||||
private void handleDatabaseChanged(final InputStream inputStream) {
|
||||
refreshTimer.record(() -> {
|
||||
try(InputStreamReader reader = new InputStreamReader(inputStream)) {
|
||||
CallRoutingTable newTable = CallRoutingTableParser.fromJson(reader);
|
||||
this.routingTable.set(newTable);
|
||||
log.info("Replaced {} call routing table: {}", tableTag, newTable.toSummaryString());
|
||||
} catch (final IOException e) {
|
||||
log.error("Failed to parse and update {} call routing table", tableTag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws Exception {
|
||||
objectMonitor.start(this::handleDatabaseChanged);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
objectMonitor.stop();
|
||||
routingTable.getAndSet(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallRoutingTable get() {
|
||||
return this.routingTable.get();
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import com.fasterxml.jackson.core.StreamReadFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
|
||||
final class CallRoutingTableParser {
|
||||
|
||||
private final static int IPV4_DEFAULT_BLOCK_SIZE = 24;
|
||||
private final static int IPV6_DEFAULT_BLOCK_SIZE = 48;
|
||||
private static final ObjectMapper objectMapper = JsonMapper.builder()
|
||||
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
|
||||
.build();
|
||||
|
||||
/** Used for parsing JSON */
|
||||
private static class RawCallRoutingTable {
|
||||
public Map<String, List<String>> ipv4GeoToDataCenters = Map.of();
|
||||
public Map<String, List<String>> ipv6GeoToDataCenters = Map.of();
|
||||
public Map<String, List<String>> ipv4SubnetsToDatacenters = Map.of();
|
||||
public Map<String, List<String>> ipv6SubnetsToDatacenters = Map.of();
|
||||
}
|
||||
|
||||
private final static String WHITESPACE_REGEX = "\\s+";
|
||||
|
||||
public static CallRoutingTable fromJson(final Reader inputReader) throws IOException {
|
||||
try (final BufferedReader reader = new BufferedReader(inputReader)) {
|
||||
RawCallRoutingTable rawTable = objectMapper.readValue(reader, RawCallRoutingTable.class);
|
||||
|
||||
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4SubnetToDatacenter = rawTable.ipv4SubnetsToDatacenters
|
||||
.entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
e -> (CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock(e.getKey(), IPV4_DEFAULT_BLOCK_SIZE),
|
||||
Map.Entry::getValue
|
||||
));
|
||||
|
||||
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6SubnetToDatacenter = rawTable.ipv6SubnetsToDatacenters
|
||||
.entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
e -> (CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock(e.getKey(), IPV6_DEFAULT_BLOCK_SIZE),
|
||||
Map.Entry::getValue
|
||||
));
|
||||
|
||||
Map<CallRoutingTable.GeoKey, List<String>> geoToDatacenter = Stream.concat(
|
||||
rawTable.ipv4GeoToDataCenters
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> Map.entry(parseRawGeoKey(e.getKey(), CallRoutingTable.Protocol.v4), e.getValue())),
|
||||
rawTable.ipv6GeoToDataCenters
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> Map.entry(parseRawGeoKey(e.getKey(), CallRoutingTable.Protocol.v6), e.getValue()))
|
||||
).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
|
||||
return new CallRoutingTable(
|
||||
ipv4SubnetToDatacenter,
|
||||
ipv6SubnetToDatacenter,
|
||||
geoToDatacenter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static CallRoutingTable.GeoKey parseRawGeoKey(String rawKey, CallRoutingTable.Protocol protocol) {
|
||||
String[] splits = rawKey.split("-");
|
||||
if (splits.length < 2 || splits.length > 3) {
|
||||
throw new IllegalArgumentException("Invalid raw key");
|
||||
}
|
||||
|
||||
Optional<String> subdivision = splits.length < 3 ? Optional.empty() : Optional.of(splits[2]);
|
||||
return new CallRoutingTable.GeoKey(splits[0], splits[1], subdivision, protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a call routing table in TSV format. Example below - see tests for more examples:
|
||||
192.0.2.0/24 northamerica-northeast1
|
||||
198.51.100.0/24 us-south1
|
||||
203.0.113.0/24 asia-southeast1
|
||||
|
||||
2001:db8:b0a9::/48 us-east4
|
||||
2001:db8:b0f5::/48 us-central1 northamerica-northeast1 us-east4
|
||||
2001:db8:9406::/48 us-east1 us-central1
|
||||
|
||||
SA-SR-v4 us-east1 us-east4
|
||||
SA-SR-v6 us-east1 us-south1
|
||||
SA-UY-v4 southamerica-west1 southamerica-east1 europe-west3
|
||||
SA-UY-v6 southamerica-west1 europe-west4
|
||||
SA-VE-v4 us-east1 us-east4 us-south1
|
||||
SA-VE-v6 us-east1 northamerica-northeast1 us-east4
|
||||
ZZ-ZZ-v4 asia-south1 europe-southwest1 australia-southeast1
|
||||
*/
|
||||
public static CallRoutingTable fromTsv(final Reader inputReader) throws IOException {
|
||||
try (final BufferedReader reader = new BufferedReader(inputReader)) {
|
||||
// use maps to silently dedupe CidrBlocks
|
||||
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4Map = new HashMap<>();
|
||||
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6Map = new HashMap<>();
|
||||
Map<CallRoutingTable.GeoKey, List<String>> ipGeoTable = new HashMap<>();
|
||||
String line;
|
||||
while((line = reader.readLine()) != null) {
|
||||
if(line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<String> splits = Arrays.stream(line.split(WHITESPACE_REGEX)).filter(s -> !s.isBlank()).toList();
|
||||
if (splits.size() < 2) {
|
||||
throw new IllegalStateException("Invalid row, expected some key and list of values");
|
||||
}
|
||||
|
||||
List<String> datacenters = splits.subList(1, splits.size());
|
||||
switch (guessLineType(splits)) {
|
||||
case v4 -> {
|
||||
CidrBlock cidrBlock = CidrBlock.parseCidrBlock(splits.getFirst());
|
||||
if(!(cidrBlock instanceof CidrBlock.IpV4CidrBlock)) {
|
||||
throw new IllegalArgumentException("Expected an ipv4 cidr block");
|
||||
}
|
||||
ipv4Map.put((CidrBlock.IpV4CidrBlock) cidrBlock, datacenters);
|
||||
}
|
||||
case v6 -> {
|
||||
CidrBlock cidrBlock = CidrBlock.parseCidrBlock(splits.getFirst());
|
||||
if(!(cidrBlock instanceof CidrBlock.IpV6CidrBlock)) {
|
||||
throw new IllegalArgumentException("Expected an ipv6 cidr block");
|
||||
}
|
||||
ipv6Map.put((CidrBlock.IpV6CidrBlock) cidrBlock, datacenters);
|
||||
}
|
||||
case Geo -> {
|
||||
String[] geo = splits.getFirst().split("-");
|
||||
if(geo.length < 3) {
|
||||
throw new IllegalStateException("Geo row key invalid, expected atleast continent, country, and protocol");
|
||||
}
|
||||
String continent = geo[0];
|
||||
String country = geo[1];
|
||||
Optional<String> subdivision = geo.length > 3 ? Optional.of(geo[2]) : Optional.empty();
|
||||
CallRoutingTable.Protocol protocol = CallRoutingTable.Protocol.valueOf(geo[geo.length - 1].toLowerCase());
|
||||
CallRoutingTable.GeoKey tableKey = new CallRoutingTable.GeoKey(
|
||||
continent,
|
||||
country,
|
||||
subdivision,
|
||||
protocol
|
||||
);
|
||||
ipGeoTable.put(tableKey, datacenters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new CallRoutingTable(
|
||||
ipv4Map,
|
||||
ipv6Map,
|
||||
ipGeoTable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static LineType guessLineType(List<String> splits) {
|
||||
String first = splits.getFirst();
|
||||
if (first.contains("-")) {
|
||||
return LineType.Geo;
|
||||
} else if(first.contains(":")) {
|
||||
return LineType.v6;
|
||||
} else if (first.contains(".")) {
|
||||
return LineType.v4;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException(String.format("Invalid line, could not determine type from '%s'", first));
|
||||
}
|
||||
|
||||
private enum LineType {
|
||||
v4, v6, Geo
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
/**
|
||||
* Can be used to check if an IP is in the CIDR block
|
||||
*/
|
||||
public interface CidrBlock {
|
||||
|
||||
boolean ipInBlock(InetAddress address);
|
||||
|
||||
static CidrBlock parseCidrBlock(String cidrBlock, int defaultBlockSize) {
|
||||
String[] splits = cidrBlock.split("/");
|
||||
if(splits.length > 2) {
|
||||
throw new IllegalArgumentException("Invalid cidr block format, expected {address}/{blocksize}");
|
||||
}
|
||||
|
||||
try {
|
||||
int blockSize = splits.length == 2 ? Integer.parseInt(splits[1]) : defaultBlockSize;
|
||||
return parseCidrBlockInner(splits[0], blockSize);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(String.format("Invalid block size specified: '%s'", splits[1]));
|
||||
}
|
||||
}
|
||||
|
||||
static CidrBlock parseCidrBlock(String cidrBlock) {
|
||||
String[] splits = cidrBlock.split("/");
|
||||
if (splits.length != 2) {
|
||||
throw new IllegalArgumentException("Invalid cidr block format, expected {address}/{blocksize}");
|
||||
}
|
||||
|
||||
try {
|
||||
int blockSize = Integer.parseInt(splits[1]);
|
||||
return parseCidrBlockInner(splits[0], blockSize);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(String.format("Invalid block size specified: '%s'", splits[1]));
|
||||
}
|
||||
}
|
||||
|
||||
private static CidrBlock parseCidrBlockInner(String rawAddress, int blockSize) {
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(rawAddress);
|
||||
if(address instanceof Inet4Address) {
|
||||
return IpV4CidrBlock.of((Inet4Address) address, blockSize);
|
||||
} else if (address instanceof Inet6Address) {
|
||||
return IpV6CidrBlock.of((Inet6Address) address, blockSize);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Must be an ipv4 or ipv6 string");
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
record IpV4CidrBlock(int subnet, int subnetMask, int cidrBlockSize) implements CidrBlock {
|
||||
public static IpV4CidrBlock of(Inet4Address subnet, int cidrBlockSize) {
|
||||
if(cidrBlockSize > 32 || cidrBlockSize < 0) {
|
||||
throw new IllegalArgumentException("Invalid cidrBlockSize");
|
||||
}
|
||||
|
||||
int subnetMask = mask(cidrBlockSize);
|
||||
int maskedIp = ipToInt(subnet) & subnetMask;
|
||||
return new IpV4CidrBlock(maskedIp, subnetMask, cidrBlockSize);
|
||||
}
|
||||
|
||||
public boolean ipInBlock(InetAddress address) {
|
||||
if(!(address instanceof Inet4Address)) {
|
||||
return false;
|
||||
}
|
||||
int ip = ipToInt((Inet4Address) address);
|
||||
return (ip & subnetMask) == subnet;
|
||||
}
|
||||
|
||||
private static int ipToInt(Inet4Address address) {
|
||||
byte[] octets = address.getAddress();
|
||||
return (octets[0] & 0xff) << 24 |
|
||||
(octets[1] & 0xff) << 16 |
|
||||
(octets[2] & 0xff) << 8 |
|
||||
octets[3] & 0xff;
|
||||
}
|
||||
|
||||
private static int mask(int cidrBlockSize) {
|
||||
return (int) (-1L << (32 - cidrBlockSize));
|
||||
}
|
||||
|
||||
public static int maskToSize(Inet4Address address, int cidrBlockSize) {
|
||||
return ipToInt(address) & mask(cidrBlockSize);
|
||||
}
|
||||
}
|
||||
|
||||
record IpV6CidrBlock(BigInteger subnet, BigInteger subnetMask, int cidrBlockSize) implements CidrBlock {
|
||||
|
||||
private static final BigInteger MINUS_ONE = BigInteger.valueOf(-1);
|
||||
|
||||
public static IpV6CidrBlock of(Inet6Address subnet, int cidrBlockSize) {
|
||||
if(cidrBlockSize > 128 || cidrBlockSize < 0) {
|
||||
throw new IllegalArgumentException("Invalid cidrBlockSize");
|
||||
}
|
||||
|
||||
BigInteger subnetMask = mask(cidrBlockSize);
|
||||
BigInteger maskedIp = ipToInt(subnet).and(subnetMask);
|
||||
return new IpV6CidrBlock(maskedIp, subnetMask, cidrBlockSize);
|
||||
}
|
||||
|
||||
public boolean ipInBlock(InetAddress address) {
|
||||
if(!(address instanceof Inet6Address)) {
|
||||
return false;
|
||||
}
|
||||
BigInteger ip = ipToInt((Inet6Address) address);
|
||||
return ip.and(subnetMask).equals(subnet);
|
||||
}
|
||||
|
||||
private static BigInteger ipToInt(Inet6Address ipAddress) {
|
||||
byte[] octets = ipAddress.getAddress();
|
||||
assert octets.length == 16;
|
||||
|
||||
return new BigInteger(octets);
|
||||
}
|
||||
|
||||
private static BigInteger mask(int cidrBlockSize) {
|
||||
return MINUS_ONE.shiftLeft(128 - cidrBlockSize);
|
||||
}
|
||||
|
||||
public static BigInteger maskToSize(Inet6Address address, int cidrBlockSize) {
|
||||
return ipToInt(address).and(mask(cidrBlockSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
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.WeightedRandomSelect;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Uses DynamicConfig to help route a turn request */
|
||||
public class DynamicConfigTurnRouter {
|
||||
|
||||
private static final Random rng = new Random();
|
||||
|
||||
public static final long RANDOMIZE_RATE_BASIS = 100_000;
|
||||
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
public DynamicConfigTurnRouter(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
public List<String> targetedUrls(final UUID aci) {
|
||||
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
|
||||
|
||||
final Optional<TurnUriConfiguration> enrolled = turnConfig.getUriConfigs().stream()
|
||||
.filter(config -> config.getEnrolledAcis().contains(aci))
|
||||
.findFirst();
|
||||
|
||||
return enrolled
|
||||
.map(turnUriConfiguration -> turnUriConfiguration.getUris().stream().toList())
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
public List<String> randomUrls() {
|
||||
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
|
||||
|
||||
// select from turn server sets by weighted choice
|
||||
return WeightedRandomSelect.select(turnConfig
|
||||
.getUriConfigs()
|
||||
.stream()
|
||||
.map(c -> new Pair<>(c.getUris(), c.getWeight())).toList());
|
||||
}
|
||||
|
||||
public String getHostname() {
|
||||
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
|
||||
return turnConfig.getHostname();
|
||||
}
|
||||
|
||||
public long getRandomizeRate() {
|
||||
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
|
||||
return turnConfig.getRandomizeRate();
|
||||
}
|
||||
|
||||
public boolean shouldRandomize() {
|
||||
long rate = getRandomizeRate();
|
||||
return rate >= RANDOMIZE_RATE_BASIS || rng.nextLong(0, DynamicConfigTurnRouter.RANDOMIZE_RATE_BASIS) < rate;
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import com.maxmind.geoip2.DatabaseReader;
|
||||
import com.maxmind.geoip2.exception.GeoIp2Exception;
|
||||
import com.maxmind.geoip2.model.CityResponse;
|
||||
import org.apache.commons.lang3.tuple.Triple;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import javax.annotation.Nonnull;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Returns routes based on performance tables, manually routing tables, and target routing. Falls back to a random Turn
|
||||
* instance that the server knows about.
|
||||
*/
|
||||
public class TurnCallRouter {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(TurnCallRouter.class);
|
||||
|
||||
private final Supplier<CallDnsRecords> callDnsRecords;
|
||||
private final Supplier<CallRoutingTable> performanceRouting;
|
||||
private final Supplier<CallRoutingTable> manualRouting;
|
||||
private final DynamicConfigTurnRouter configTurnRouter;
|
||||
private final Supplier<DatabaseReader> geoIp;
|
||||
// controls whether instance IPs are shuffled. using if & boolean is ~5x faster than a function pointer
|
||||
private final boolean stableSelect;
|
||||
|
||||
public TurnCallRouter(
|
||||
@Nonnull Supplier<CallDnsRecords> callDnsRecords,
|
||||
@Nonnull Supplier<CallRoutingTable> performanceRouting,
|
||||
@Nonnull Supplier<CallRoutingTable> manualRouting,
|
||||
@Nonnull DynamicConfigTurnRouter configTurnRouter,
|
||||
@Nonnull Supplier<DatabaseReader> geoIp,
|
||||
boolean stableSelect
|
||||
) {
|
||||
this.performanceRouting = performanceRouting;
|
||||
this.callDnsRecords = callDnsRecords;
|
||||
this.manualRouting = manualRouting;
|
||||
this.configTurnRouter = configTurnRouter;
|
||||
this.geoIp = geoIp;
|
||||
this.stableSelect = stableSelect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Turn Instance addresses. Returns both the IPv4 and IPv6 addresses. Prefers to match the IP protocol of the
|
||||
* client address in datacenter selection. Returns 2 instance options of the preferred protocol for every one instance
|
||||
* of the other.
|
||||
* @param aci aci of client
|
||||
* @param clientAddress IP address to base routing on
|
||||
* @param instanceLimit max instances to return options for
|
||||
* @return Up to two * instanceLimit options, half in ipv4, half in ipv6
|
||||
*/
|
||||
public TurnServerOptions getRoutingFor(
|
||||
@Nonnull final UUID aci,
|
||||
@Nonnull final Optional<InetAddress> clientAddress,
|
||||
final int instanceLimit
|
||||
) {
|
||||
try {
|
||||
return getRoutingForInner(aci, clientAddress, instanceLimit);
|
||||
} catch(Exception e) {
|
||||
logger.error("Failed to perform routing", e);
|
||||
return new TurnServerOptions(this.configTurnRouter.getHostname(), null, this.configTurnRouter.randomUrls());
|
||||
}
|
||||
}
|
||||
|
||||
TurnServerOptions getRoutingForInner(
|
||||
@Nonnull final UUID aci,
|
||||
@Nonnull final Optional<InetAddress> clientAddress,
|
||||
final int instanceLimit
|
||||
) {
|
||||
if (instanceLimit < 1) {
|
||||
throw new IllegalArgumentException("Limit cannot be less than one");
|
||||
}
|
||||
|
||||
String hostname = this.configTurnRouter.getHostname();
|
||||
|
||||
List<String> targetedUrls = this.configTurnRouter.targetedUrls(aci);
|
||||
if(!targetedUrls.isEmpty()) {
|
||||
return new TurnServerOptions(hostname, null, targetedUrls);
|
||||
}
|
||||
|
||||
if(clientAddress.isEmpty() || this.configTurnRouter.shouldRandomize()) {
|
||||
return new TurnServerOptions(hostname, null, this.configTurnRouter.randomUrls());
|
||||
}
|
||||
|
||||
CityResponse geoInfo;
|
||||
try {
|
||||
geoInfo = geoIp.get().city(clientAddress.get());
|
||||
} catch (IOException | GeoIp2Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
Optional<String> subdivision = !geoInfo.getSubdivisions().isEmpty()
|
||||
? Optional.of(geoInfo.getSubdivisions().getFirst().getIsoCode())
|
||||
: Optional.empty();
|
||||
|
||||
List<String> datacenters = this.manualRouting.get().getDatacentersFor(
|
||||
clientAddress.get(),
|
||||
geoInfo.getContinent().getCode(),
|
||||
geoInfo.getCountry().getIsoCode(),
|
||||
subdivision
|
||||
);
|
||||
|
||||
if (datacenters.isEmpty()){
|
||||
datacenters = this.performanceRouting.get().getDatacentersFor(
|
||||
clientAddress.get(),
|
||||
geoInfo.getContinent().getCode(),
|
||||
geoInfo.getCountry().getIsoCode(),
|
||||
subdivision
|
||||
);
|
||||
}
|
||||
|
||||
List<String> urlsWithIps = getUrlsForInstances(
|
||||
selectInstances(
|
||||
datacenters,
|
||||
instanceLimit
|
||||
));
|
||||
return new TurnServerOptions(hostname, urlsWithIps, minimalRandomUrls());
|
||||
}
|
||||
|
||||
// Includes only the udp options in the randomUrls
|
||||
private List<String> minimalRandomUrls(){
|
||||
return this.configTurnRouter.randomUrls().stream()
|
||||
.filter(s -> s.startsWith("turn:") && !s.endsWith("transport=tcp"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// returns balanced number of instances across provided datacenters, prioritizing the datacenters earlier in the list
|
||||
private List<String> selectInstances(List<String> datacenters, int instanceLimit) {
|
||||
if(datacenters.isEmpty() || instanceLimit == 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
CallDnsRecords dnsRecords = this.callDnsRecords.get();
|
||||
List<List<InetAddress>> ipv4Options = datacenters.stream()
|
||||
.map(dc -> randomNOf(dnsRecords.aByRegion().get(dc), instanceLimit, stableSelect))
|
||||
.toList();
|
||||
List<List<InetAddress>> ipv6Options = datacenters.stream()
|
||||
.map(dc -> randomNOf(dnsRecords.aaaaByRegion().get(dc), instanceLimit, stableSelect))
|
||||
.toList();
|
||||
|
||||
List<InetAddress> ipv4Selection = selectFromOptions(ipv4Options, instanceLimit);
|
||||
List<InetAddress> ipv6Selection = selectFromOptions(ipv6Options, instanceLimit);
|
||||
|
||||
return Stream.concat(
|
||||
ipv4Selection.stream().map(InetAddress::getHostAddress),
|
||||
// map ipv6 to RFC3986 format i.e. surrounded by brackets
|
||||
ipv6Selection.stream().map(i -> String.format("[%s]", i.getHostAddress()))
|
||||
).toList();
|
||||
}
|
||||
|
||||
private static List<InetAddress> selectFromOptions(List<List<InetAddress>> recordsByDc, int instanceLimit) {
|
||||
return IntStream.range(0, recordsByDc.size())
|
||||
.mapToObj(dcIndex -> IntStream.range(0, recordsByDc.get(dcIndex).size())
|
||||
.mapToObj(addressIndex -> Triple.of(addressIndex, dcIndex, recordsByDc.get(dcIndex).get(addressIndex))))
|
||||
.flatMap(i -> i)
|
||||
.sorted(Comparator.comparingInt((Triple<Integer, Integer, InetAddress> t) -> t.getLeft())
|
||||
.thenComparingInt(Triple::getMiddle))
|
||||
.limit(instanceLimit)
|
||||
.sorted(Comparator.comparingInt(Triple::getMiddle))
|
||||
.map(Triple::getRight)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static <E> List<E> randomNOf(List<E> values, int n, boolean stableSelect) {
|
||||
return stableSelect ? Util.randomNOfStable(values, n) : Util.randomNOfShuffled(values, n);
|
||||
}
|
||||
|
||||
private static List<String> getUrlsForInstances(List<String> instanceIps) {
|
||||
return instanceIps.stream().flatMap(ip -> Stream.of(
|
||||
String.format("turn:%s", ip),
|
||||
String.format("turn:%s:80?transport=tcp", ip),
|
||||
String.format("turns:%s:443?transport=tcp", ip)
|
||||
)
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.calls.routing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TurnServerOptions(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
|
||||
}
|
||||
@@ -9,14 +9,13 @@ import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -32,20 +31,20 @@ public class CaptchaChecker {
|
||||
private static final String SHORT_SUFFIX = "-short";
|
||||
|
||||
private final ShortCodeExpander shortCodeExpander;
|
||||
private final Map<String, CaptchaClient> captchaClientMap;
|
||||
private final Function<String, CaptchaClient> captchaClientSupplier;
|
||||
|
||||
public CaptchaChecker(
|
||||
final ShortCodeExpander shortCodeRetriever,
|
||||
final List<CaptchaClient> captchaClients) {
|
||||
final Function<String, CaptchaClient> captchaClientSupplier) {
|
||||
this.shortCodeExpander = shortCodeRetriever;
|
||||
this.captchaClientMap = captchaClients.stream()
|
||||
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
|
||||
this.captchaClientSupplier = captchaClientSupplier;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a solved captcha should be accepted
|
||||
*
|
||||
* @param maybeAci optional account UUID of the user solving the captcha
|
||||
* @param expectedAction the {@link Action} for which this captcha solution is intended
|
||||
* @param input expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The
|
||||
* expected format is {@code version-prefix.sitekey.action.token}
|
||||
@@ -57,6 +56,7 @@ public class CaptchaChecker {
|
||||
* @throws BadRequestException if input is not in the expected format
|
||||
*/
|
||||
public AssessmentResult verify(
|
||||
final Optional<UUID> maybeAci,
|
||||
final Action expectedAction,
|
||||
final String input,
|
||||
final String ip,
|
||||
@@ -81,7 +81,7 @@ public class CaptchaChecker {
|
||||
token = shortCodeExpander.retrieve(token).orElseThrow(() -> new BadRequestException("invalid shortcode"));
|
||||
}
|
||||
|
||||
final CaptchaClient client = this.captchaClientMap.get(provider);
|
||||
final CaptchaClient client = this.captchaClientSupplier.apply(provider);
|
||||
if (client == null) {
|
||||
throw new BadRequestException("invalid captcha scheme");
|
||||
}
|
||||
@@ -104,7 +104,7 @@ public class CaptchaChecker {
|
||||
throw new BadRequestException("invalid captcha site-key");
|
||||
}
|
||||
|
||||
final AssessmentResult result = client.verify(siteKey, parsedAction, token, ip, userAgent);
|
||||
final AssessmentResult result = client.verify(maybeAci, siteKey, parsedAction, token, ip, userAgent);
|
||||
Metrics.counter(ASSESSMENTS_COUNTER_NAME,
|
||||
"action", action,
|
||||
"score", result.getScoreString(),
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.captcha;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CaptchaClient {
|
||||
|
||||
@@ -26,6 +27,7 @@ public interface CaptchaClient {
|
||||
/**
|
||||
* Verify a provided captcha solution
|
||||
*
|
||||
* @param maybeAci optional account service identifier of the user
|
||||
* @param siteKey identifying string for the captcha service
|
||||
* @param action an action indicating the purpose of the captcha
|
||||
* @param token the captcha solution that will be verified
|
||||
@@ -35,9 +37,30 @@ public interface CaptchaClient {
|
||||
* @throws IOException if the underlying captcha provider returns an error
|
||||
*/
|
||||
AssessmentResult verify(
|
||||
final Optional<UUID> maybeAci,
|
||||
final String siteKey,
|
||||
final Action action,
|
||||
final String token,
|
||||
final String ip,
|
||||
final String userAgent) throws IOException;
|
||||
|
||||
static CaptchaClient noop() {
|
||||
return new CaptchaClient() {
|
||||
@Override
|
||||
public String scheme() {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validSiteKeys(final Action action) {
|
||||
return Set.of("noop");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssessmentResult verify(final Optional<UUID> maybeAci, final String siteKey, final Action action, final String token, final String ip,
|
||||
final String userAgent) throws IOException {
|
||||
return AssessmentResult.alwaysValid();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.ws.rs.core.Response;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public class HCaptchaClient implements CaptchaClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HCaptchaClient.class);
|
||||
private static final String PREFIX = "signal-hcaptcha";
|
||||
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason");
|
||||
private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason");
|
||||
private final String apiKey;
|
||||
private final FaultTolerantHttpClient client;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
@VisibleForTesting
|
||||
HCaptchaClient(final String apiKey,
|
||||
final FaultTolerantHttpClient faultTolerantHttpClient,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.apiKey = apiKey;
|
||||
this.client = faultTolerantHttpClient;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
public HCaptchaClient(
|
||||
final String apiKey,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final ExecutorService httpExecutor,
|
||||
final CircuitBreakerConfiguration circuitBreakerConfiguration,
|
||||
final RetryConfiguration retryConfiguration,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this(apiKey,
|
||||
FaultTolerantHttpClient.newBuilder()
|
||||
.withName("hcaptcha")
|
||||
.withCircuitBreaker(circuitBreakerConfiguration)
|
||||
.withExecutor(httpExecutor)
|
||||
.withRetryExecutor(retryExecutor)
|
||||
.withRetry(retryConfiguration)
|
||||
.withRetryOnException(ex -> ex instanceof IOException)
|
||||
.withConnectTimeout(Duration.ofSeconds(10))
|
||||
.withVersion(HttpClient.Version.HTTP_2)
|
||||
.build(),
|
||||
dynamicConfigurationManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String scheme() {
|
||||
return PREFIX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validSiteKeys(final Action action) {
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
if (!config.isAllowHCaptcha()) {
|
||||
logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled");
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return Optional
|
||||
.ofNullable(config.getHCaptchaSiteKeys().get(action))
|
||||
.orElse(Collections.emptySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssessmentResult verify(
|
||||
final String siteKey,
|
||||
final Action action,
|
||||
final String token,
|
||||
final String ip,
|
||||
final String userAgent)
|
||||
throws IOException {
|
||||
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
final String body = String.format("response=%s&secret=%s&remoteip=%s",
|
||||
URLEncoder.encode(token, StandardCharsets.UTF_8),
|
||||
URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8),
|
||||
ip);
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://hcaptcha.com/siteverify"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> response;
|
||||
try {
|
||||
response = this.client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
|
||||
} catch (CompletionException e) {
|
||||
logger.warn("failed to make http request to hCaptcha: {}", e.getMessage());
|
||||
throw new IOException(ExceptionUtils.unwrap(e));
|
||||
}
|
||||
|
||||
if (response.statusCode() != Response.Status.OK.getStatusCode()) {
|
||||
logger.warn("failure submitting token to hCaptcha (code={}): {}", response.statusCode(), response);
|
||||
throw new IOException("hCaptcha http failure : " + response.statusCode());
|
||||
}
|
||||
|
||||
final HCaptchaResponse hCaptchaResponse = SystemMapper.jsonMapper()
|
||||
.readValue(response.body(), HCaptchaResponse.class);
|
||||
|
||||
logger.debug("received hCaptcha response: {}", hCaptchaResponse);
|
||||
|
||||
if (!hCaptchaResponse.success) {
|
||||
for (String errorCode : hCaptchaResponse.errorCodes) {
|
||||
Metrics.counter(INVALID_REASON_COUNTER_NAME, Tags.of(
|
||||
Tag.of("action", action.getActionName()),
|
||||
Tag.of("reason", errorCode),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent)
|
||||
)).increment();
|
||||
}
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
|
||||
// hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky)
|
||||
final float score = 1.0f - hCaptchaResponse.score;
|
||||
if (score < 0.0f || score > 1.0f) {
|
||||
logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
|
||||
final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());
|
||||
|
||||
for (String reason : hCaptchaResponse.scoreReasons) {
|
||||
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
|
||||
"action", action.getActionName(),
|
||||
"reason", reason,
|
||||
"score", assessmentResult.getScoreString()).increment();
|
||||
}
|
||||
return assessmentResult;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Verify response returned by hcaptcha
|
||||
* <p>
|
||||
* see <a href="https://docs.hcaptcha.com/#verify-the-user-response-server-side">...</a>
|
||||
*/
|
||||
public class HCaptchaResponse {
|
||||
|
||||
@JsonProperty
|
||||
boolean success;
|
||||
|
||||
@JsonProperty(value = "challenge_ts")
|
||||
Instant challengeTs;
|
||||
|
||||
@JsonProperty
|
||||
String hostname;
|
||||
|
||||
@JsonProperty(value = "error-codes")
|
||||
List<String> errorCodes = Collections.emptyList();
|
||||
|
||||
@JsonProperty
|
||||
float score;
|
||||
|
||||
@JsonProperty(value = "score_reason")
|
||||
List<String> scoreReasons = Collections.emptyList();
|
||||
|
||||
public HCaptchaResponse() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HCaptchaResponse{" +
|
||||
"success=" + success +
|
||||
", challengeTs=" + challengeTs +
|
||||
", hostname='" + hostname + '\'' +
|
||||
", errorCodes=" + errorCodes +
|
||||
", score=" + score +
|
||||
", scoreReasons=" + scoreReasons +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class RegistrationCaptchaManager {
|
||||
|
||||
@@ -17,10 +18,10 @@ public class RegistrationCaptchaManager {
|
||||
}
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public Optional<AssessmentResult> assessCaptcha(final Optional<String> captcha, final String sourceHost, final String userAgent)
|
||||
public Optional<AssessmentResult> assessCaptcha(final Optional<UUID> aci, final Optional<String> captcha, final String sourceHost, final String userAgent)
|
||||
throws IOException {
|
||||
return captcha.isPresent()
|
||||
? Optional.of(captchaChecker.verify(Action.REGISTRATION, captcha.get(), sourceHost, userAgent))
|
||||
? Optional.of(captchaChecker.verify(aci, Action.REGISTRATION, captcha.get(), sourceHost, userAgent))
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables.Table;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class AccountsTableConfiguration extends Table {
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
|
||||
@JsonTypeName("default")
|
||||
public class AppConfigConfiguration implements DynamicConfigurationManagerFactory {
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String application;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String environment;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String configuration;
|
||||
|
||||
public String getApplication() {
|
||||
return application;
|
||||
}
|
||||
|
||||
public String getEnvironment() {
|
||||
return environment;
|
||||
}
|
||||
|
||||
public String getConfigurationName() {
|
||||
return configuration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> DynamicConfigurationManager<T> build(Class<T> klazz, ScheduledExecutorService scheduledExecutorService,
|
||||
AwsCredentialsProvider awsCredentialsProvider) {
|
||||
return new DynamicConfigurationManager<>(application, environment, configuration, awsCredentialsProvider, klazz,
|
||||
scheduledExecutorService);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.apple.itunes.storekit.model.Environment;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Configuration for Apple DeviceCheck
|
||||
*
|
||||
* @param production Whether this is for production or sandbox attestations
|
||||
* @param teamId The teamId to validate attestations against
|
||||
* @param bundleId The bundleId to validation attestations against
|
||||
*/
|
||||
public record AppleDeviceCheckConfiguration(
|
||||
boolean production,
|
||||
String teamId,
|
||||
String bundleId) {}
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import static org.apache.commons.lang3.ObjectUtils.firstNonNull;
|
||||
|
||||
import java.time.Duration;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public record ArtServiceConfiguration(@ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,
|
||||
@NotNull SecretBytes userAuthenticationTokenUserIdSecret,
|
||||
@NotNull Duration tokenExpiration) {
|
||||
public ArtServiceConfiguration {
|
||||
tokenExpiration = firstNonNull(tokenExpiration, Duration.ofDays(1));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public record AwsAttachmentsConfiguration(@NotNull @Valid StaticAwsCredentialsFactory credentials,
|
||||
@NotBlank String bucket,
|
||||
@NotBlank String region) {
|
||||
}
|
||||
@@ -7,9 +7,9 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonSetter;
|
||||
import com.fasterxml.jackson.annotation.Nulls;
|
||||
import io.dropwizard.validation.ValidationMethod;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class BadgesConfiguration {
|
||||
private final List<BadgeConfiguration> badges;
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CdnConfiguration(@NotNull @Valid StaticAwsCredentialsFactory credentials,
|
||||
@NotBlank String bucket,
|
||||
|
||||
@@ -8,13 +8,13 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class CircuitBreakerConfiguration {
|
||||
|
||||
@@ -116,7 +116,8 @@ public class CircuitBreakerConfiguration {
|
||||
.permittedNumberOfCallsInHalfOpenState(getPermittedNumberOfCallsInHalfOpenState())
|
||||
.waitDurationInOpenState(getWaitDurationInOpenState())
|
||||
.slidingWindow(getSlidingWindowSize(), getSlidingWindowMinimumNumberOfCalls(),
|
||||
CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
|
||||
CircuitBreakerConfig.SlidingWindowType.COUNT_BASED,
|
||||
CircuitBreakerConfig.SlidingWindowSynchronizationStrategy.SYNCHRONIZED)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* Configuration used to interact with a cdn via HTTP
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
|
||||
public record ClientReleaseConfiguration(@NotNull Duration refreshInterval) {
|
||||
|
||||
@@ -5,20 +5,44 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
/**
|
||||
* Configuration properties for Cloudflare TURN integration.
|
||||
*
|
||||
* @param apiToken the API token to use when requesting TURN tokens from Cloudflare
|
||||
* @param endpoint the URI of the Cloudflare API endpoint that vends TURN tokens
|
||||
* @param requestedCredentialTtl the lifetime of TURN tokens to request from Cloudflare
|
||||
* @param clientCredentialTtl the time clients may cache a TURN token; must be less than or equal to {@link #requestedCredentialTtl}
|
||||
* @param urls a collection of TURN URLs to include verbatim in responses to clients
|
||||
* @param urlsWithIps a collection of {@link String#format(String, Object...)} patterns to be populated with resolved IP
|
||||
* addresses for {@link #hostname} in responses to clients; each pattern must include a single
|
||||
* {@code %s} placeholder for the IP address
|
||||
* @param circuitBreaker a circuit breaker for requests to Cloudflare
|
||||
* @param retry a retry policy for requests to Cloudflare
|
||||
* @param hostname the hostname to resolve to IP addresses for use with {@link #urlsWithIps}; also transmitted to
|
||||
* clients for use as an SNI when connecting to pre-resolved hosts
|
||||
* @param numHttpClients the number of parallel HTTP clients to use to communicate with Cloudflare
|
||||
*/
|
||||
public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
||||
@NotBlank String endpoint,
|
||||
@NotBlank long ttl,
|
||||
@NotBlank List<String> urls,
|
||||
@NotBlank List<String> urlsWithIps,
|
||||
@NotNull Duration requestedCredentialTtl,
|
||||
@NotNull Duration clientCredentialTtl,
|
||||
@NotNull @NotEmpty @Valid List<@NotBlank String> urls,
|
||||
@NotNull @NotEmpty @Valid List<@NotBlank String> urlsWithIps,
|
||||
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
|
||||
@NotNull @Valid RetryConfiguration retry,
|
||||
@NotBlank String hostname) {
|
||||
@NotBlank String hostname,
|
||||
@Positive int numHttpClients) {
|
||||
|
||||
public CloudflareTurnConfiguration {
|
||||
if (circuitBreaker == null) {
|
||||
@@ -32,4 +56,10 @@ public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
||||
retry = new RetryConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean isClientTtlShorterThanRequestedTtl() {
|
||||
return clientCredentialTtl.compareTo(requestedCredentialTtl) <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ import com.google.auth.oauth2.ExternalAccountCredentials;
|
||||
import com.google.cloud.pubsub.v1.Publisher;
|
||||
import com.google.cloud.pubsub.v1.PublisherInterface;
|
||||
import com.google.pubsub.v1.TopicName;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@JsonTypeName("default")
|
||||
public record DefaultPubSubPublisherFactory(@NotBlank String project,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Configuration for Device Check operations
|
||||
*
|
||||
* @param backupRedemptionDuration How long to grant backup access for redemptions via device check
|
||||
* @param backupRedemptionLevel What backup level to grant redemptions via device check
|
||||
*/
|
||||
public record DeviceCheckConfiguration(Duration backupRedemptionDuration, long backupRedemptionLevel) {}
|
||||
@@ -6,7 +6,7 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.Valid;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
public class DirectoryV2Configuration {
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import io.micrometer.statsd.StatsdFlavor;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
@JsonTypeName("default")
|
||||
public class DogstatsdConfiguration implements DatadogConfiguration {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.dropwizard.jackson.Discoverable;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = AppConfigConfiguration.class)
|
||||
public interface DynamicConfigurationManagerFactory extends Discoverable {
|
||||
|
||||
<T> DynamicConfigurationManager<T> build(Class<T> configurationClass,
|
||||
ScheduledExecutorService scheduledExecutorService, AwsCredentialsProvider awsCredentialsProvider);
|
||||
}
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Positive;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.time.Duration;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
|
||||
import software.amazon.awssdk.http.crt.AwsCrtHttpClient;
|
||||
|
||||
@@ -7,10 +7,10 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class DynamoDbTables {
|
||||
|
||||
@@ -48,6 +48,8 @@ public class DynamoDbTables {
|
||||
|
||||
private final AccountsTableConfiguration accounts;
|
||||
|
||||
private final Table appleDeviceChecks;
|
||||
private final Table appleDeviceCheckPublicKeys;
|
||||
private final Table backups;
|
||||
private final Table clientPublicKeys;
|
||||
private final Table clientReleases;
|
||||
@@ -58,6 +60,7 @@ public class DynamoDbTables {
|
||||
private final Table ecSignedPreKeys;
|
||||
private final Table kemKeys;
|
||||
private final Table kemLastResortKeys;
|
||||
private final Table pagedKemKeys;
|
||||
private final TableWithExpiration messages;
|
||||
private final TableWithExpiration onetimeDonations;
|
||||
private final Table phoneNumberIdentifiers;
|
||||
@@ -74,6 +77,8 @@ public class DynamoDbTables {
|
||||
|
||||
public DynamoDbTables(
|
||||
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
|
||||
@JsonProperty("appleDeviceChecks") final Table appleDeviceChecks,
|
||||
@JsonProperty("appleDeviceCheckPublicKeys") final Table appleDeviceCheckPublicKeys,
|
||||
@JsonProperty("backups") final Table backups,
|
||||
@JsonProperty("clientPublicKeys") final Table clientPublicKeys,
|
||||
@JsonProperty("clientReleases") final Table clientReleases,
|
||||
@@ -84,6 +89,7 @@ public class DynamoDbTables {
|
||||
@JsonProperty("ecSignedPreKeys") final Table ecSignedPreKeys,
|
||||
@JsonProperty("pqKeys") final Table kemKeys,
|
||||
@JsonProperty("pqLastResortKeys") final Table kemLastResortKeys,
|
||||
@JsonProperty("pagedPqKeys") final Table pagedKemKeys,
|
||||
@JsonProperty("messages") final TableWithExpiration messages,
|
||||
@JsonProperty("onetimeDonations") final TableWithExpiration onetimeDonations,
|
||||
@JsonProperty("phoneNumberIdentifiers") final Table phoneNumberIdentifiers,
|
||||
@@ -99,6 +105,8 @@ public class DynamoDbTables {
|
||||
@JsonProperty("verificationSessions") final Table verificationSessions) {
|
||||
|
||||
this.accounts = accounts;
|
||||
this.appleDeviceChecks = appleDeviceChecks;
|
||||
this.appleDeviceCheckPublicKeys = appleDeviceCheckPublicKeys;
|
||||
this.backups = backups;
|
||||
this.clientPublicKeys = clientPublicKeys;
|
||||
this.clientReleases = clientReleases;
|
||||
@@ -108,6 +116,7 @@ public class DynamoDbTables {
|
||||
this.ecKeys = ecKeys;
|
||||
this.ecSignedPreKeys = ecSignedPreKeys;
|
||||
this.kemKeys = kemKeys;
|
||||
this.pagedKemKeys = pagedKemKeys;
|
||||
this.kemLastResortKeys = kemLastResortKeys;
|
||||
this.messages = messages;
|
||||
this.onetimeDonations = onetimeDonations;
|
||||
@@ -130,6 +139,18 @@ public class DynamoDbTables {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getAppleDeviceChecks() {
|
||||
return appleDeviceChecks;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getAppleDeviceCheckPublicKeys() {
|
||||
return appleDeviceCheckPublicKeys;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getBackups() {
|
||||
@@ -184,6 +205,12 @@ public class DynamoDbTables {
|
||||
return kemKeys;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getPagedKemKeys() {
|
||||
return pagedKemKeys;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getKemLastResortKeys() {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Set;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.util.InetAddressRange;
|
||||
|
||||
public record ExternalRequestFilterConfiguration(@Valid @NotNull Set<@NotNull String> paths,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
public record FcmConfiguration(@NotNull SecretString credentials) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user