From a450e13a9954862124f5c8b77cc612e42ed486dd Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Fri, 8 Jul 2022 13:46:25 -0700 Subject: [PATCH] Fetch PNI group credentials --- .github/workflows/ci.yml | 8 + config/default.json | 4 +- config/production.json | 2 +- package.json | 4 +- protos/Groups.proto | 14 +- ts/background.ts | 37 +- .../conversation/GroupV2Change.stories.tsx | 167 ++--- ts/components/conversation/GroupV2Change.tsx | 21 +- .../ChooseGroupMembersModal.tsx | 6 +- ts/groupChange.ts | 43 +- ts/groups.ts | 613 +++++++++++++----- ts/groups/joinViaLink.tsx | 15 +- ts/model-types.d.ts | 4 +- ts/models/conversations.ts | 524 ++++++++------- ts/models/messages.ts | 38 +- ts/routineProfileRefresh.ts | 97 ++- ts/services/groupCredentialFetcher.ts | 100 +-- ts/sql/Client.ts | 9 +- ts/sql/Interface.ts | 13 +- ts/sql/Server.ts | 17 +- ts/state/ducks/calling.ts | 2 +- ts/state/ducks/stories.ts | 3 +- ts/state/ducks/user.ts | 12 +- ts/state/getInitialState.ts | 11 +- ts/state/selectors/calling.ts | 4 +- ts/state/selectors/conversations.ts | 12 +- ts/state/selectors/message.ts | 45 +- ts/state/selectors/user.ts | 9 +- ts/state/smart/ConversationHeader.tsx | 8 +- ts/state/smart/MainHeader.tsx | 2 - ts/test-both/groups/add_banned_member_test.ts | 10 +- .../routineProfileRefresh_test.ts | 2 + ts/test-electron/state/ducks/calling_test.ts | 4 +- .../state/selectors/calling_test.ts | 11 +- ts/test-electron/util/sendToGroup_test.ts | 41 +- ts/test-mock/benchmarks/convo_open_bench.ts | 19 +- ts/test-mock/benchmarks/fixtures.ts | 15 - ts/test-mock/benchmarks/group_send_bench.ts | 11 +- ts/test-mock/benchmarks/send_bench.ts | 18 +- ts/test-mock/benchmarks/startup_bench.ts | 4 +- ts/test-mock/benchmarks/storage_sync_bench.ts | 10 +- ts/test-mock/bootstrap.ts | 41 +- ts/test-mock/gv2/accept_invite_test.ts | 240 +++++++ ts/test-mock/gv2/create_test.ts | 6 +- ts/test-mock/storage/archive_test.ts | 10 +- ts/test-mock/storage/drop_test.ts | 10 +- ts/test-mock/storage/fixtures.ts | 113 ++-- ts/test-mock/storage/max_read_keys_test.ts | 10 +- ts/test-mock/storage/message_request_test.ts | 10 +- ts/test-mock/storage/pin_unpin_test.ts | 10 +- ts/textsecure/AccountManager.ts | 10 +- ts/textsecure/MessageReceiver.ts | 4 +- ts/textsecure/Types.d.ts | 4 +- ts/textsecure/WebAPI.ts | 33 +- ts/textsecure/messageReceiverEvents.ts | 15 +- ts/util/getProfile.ts | 93 ++- ts/util/handleRetry.ts | 5 +- ts/util/sendToGroup.ts | 15 +- ts/util/zkgroup.ts | 140 +++- ts/views/conversation_view.tsx | 2 +- yarn.lock | 26 +- 61 files changed, 1911 insertions(+), 875 deletions(-) create mode 100644 ts/test-mock/gv2/accept_invite_test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffa7348eb..43b0e8c47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,3 +207,11 @@ jobs: env: NODE_ENV: production DEBUG: mock:test:* + ARTIFACTS_DIR: artifacts/startup + + - name: Upload mock server test logs on failure + if: failure() + uses: actions/upload-artifact@v2 + with: + name: logs + path: artifacts diff --git a/config/default.json b/config/default.json index d640a8817..7b3fbad42 100644 --- a/config/default.json +++ b/config/default.json @@ -9,7 +9,7 @@ "directoryV2PublicKey": null, "directoryV2CodeHashes": null, "directoryV3Url": "https://cdsi.staging.signal.org", - "directoryV3MRENCLAVE": "51133fecb3fa18aaf0c8f64cb763656d3272d9faaacdb26ae7df082e414fb142", + "directoryV3MRENCLAVE": "e5eaa62da3514e8b37ccabddb87e52e7f319ccf5120a13f9e1b42b87ec9dd3dd", "directoryV3Root": "-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n", "cdn": { "0": "https://cdn-staging.signal.org", @@ -26,6 +26,6 @@ "buildCreation": 0, "buildExpiration": 0, "certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIIF2zCCA8OgAwIBAgIUAMHz4g60cIDBpPr1gyZ/JDaaPpcwDQYJKoZIhvcNAQEL\nBQAwdTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcT\nDU1vdW50YWluIFZpZXcxHjAcBgNVBAoTFVNpZ25hbCBNZXNzZW5nZXIsIExMQzEZ\nMBcGA1UEAxMQU2lnbmFsIE1lc3NlbmdlcjAeFw0yMjAxMjYwMDQ1NTFaFw0zMjAx\nMjQwMDQ1NTBaMHUxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYw\nFAYDVQQHEw1Nb3VudGFpbiBWaWV3MR4wHAYDVQQKExVTaWduYWwgTWVzc2VuZ2Vy\nLCBMTEMxGTAXBgNVBAMTEFNpZ25hbCBNZXNzZW5nZXIwggIiMA0GCSqGSIb3DQEB\nAQUAA4ICDwAwggIKAoICAQDEecifxMHHlDhxbERVdErOhGsLO08PUdNkATjZ1kT5\n1uPf5JPiRbus9F4J/GgBQ4ANSAjIDZuFY0WOvG/i0qvxthpW70ocp8IjkiWTNiA8\n1zQNQdCiWbGDU4B1sLi2o4JgJMweSkQFiyDynqWgHpw+KmvytCzRWnvrrptIfE4G\nPxNOsAtXFbVH++8JO42IaKRVlbfpe/lUHbjiYmIpQroZPGPY4Oql8KM3o39ObPnT\no1WoM4moyOOZpU3lV1awftvWBx1sbTBL02sQWfHRxgNVF+Pj0fdDMMFdFJobArrL\nVfK2Ua+dYN4pV5XIxzVarSRW73CXqQ+2qloPW/ynpa3gRtYeGWV4jl7eD0PmeHpK\nOY78idP4H1jfAv0TAVeKpuB5ZFZ2szcySxrQa8d7FIf0kNJe9gIRjbQ+XrvnN+ZZ\nvj6d+8uBJq8LfQaFhlVfI0/aIdggScapR7w8oLpvdflUWqcTLeXVNLVrg15cEDwd\nlV8PVscT/KT0bfNzKI80qBq8LyRmauAqP0CDjayYGb2UAabnhefgmRY6aBE5mXxd\nbyAEzzCS3vDxjeTD8v8nbDq+SD6lJi0i7jgwEfNDhe9XK50baK15Udc8Cr/ZlhGM\njNmWqBd0jIpaZm1rzWA0k4VwXtDwpBXSz8oBFshiXs3FD6jHY2IhOR3ppbyd4qRU\npwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV\nHQ4EFgQUtfNLxuXWS9DlgGuMUMNnW7yx83EwHwYDVR0jBBgwFoAUtfNLxuXWS9Dl\ngGuMUMNnW7yx83EwDQYJKoZIhvcNAQELBQADggIBABUeiryS0qjykBN75aoHO9bV\nPrrX+DSJIB9V2YzkFVyh/io65QJMG8naWVGOSpVRwUwhZVKh3JVp/miPgzTGAo7z\nhrDIoXc+ih7orAMb19qol/2Ha8OZLa75LojJNRbZoCR5C+gM8C+spMLjFf9k3JVx\ndajhtRUcR0zYhwsBS7qZ5Me0d6gRXD0ZiSbadMMxSw6KfKk3ePmPb9gX+MRTS63c\n8mLzVYB/3fe/bkpq4RUwzUHvoZf+SUD7NzSQRQQMfvAHlxk11TVNxScYPtxXDyiy\n3Cssl9gWrrWqQ/omuHipoH62J7h8KAYbr6oEIq+Czuenc3eCIBGBBfvCpuFOgckA\nXXE4MlBasEU0MO66GrTCgMt9bAmSw3TrRP12+ZUFxYNtqWluRU8JWQ4FCCPcz9pg\nMRBOgn4lTxDZG+I47OKNuSRjFEP94cdgxd3H/5BK7WHUz1tAGQ4BgepSXgmjzifF\nT5FVTDTl3ZnWUVBXiHYtbOBgLiSIkbqGMCLtrBtFIeQ7RRTb3L+IE9R0UB0cJB3A\nXbf1lVkOcmrdu2h8A32aCwtr5S1fBF1unlG7imPmqJfpOMWa8yIF/KWVm29JAPq8\nLrsybb0z5gg8w7ZblEuB9zOW9M3l60DXuJO6l7g+deV6P96rv2unHS8UlvWiVWDy\n9qfgAJizyy3kqM4lOwBH\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n", - "serverPublicParams": "ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXQ==", + "serverPublicParams": "ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj", "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx" } diff --git a/config/production.json b/config/production.json index b407570ac..9d05e7f06 100644 --- a/config/production.json +++ b/config/production.json @@ -9,7 +9,7 @@ "0": "https://cdn.signal.org", "2": "https://cdn2.signal.org" }, - "serverPublicParams": "AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXQ==", + "serverPublicParams": "AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P", "serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF", "updatesEnabled": true } diff --git a/package.json b/package.json index 834b61a94..7ac4253bd 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@indutny/frameless-titlebar": "2.3.4", "@popperjs/core": "2.9.2", "@react-spring/web": "9.4.5", - "@signalapp/libsignal-client": "0.17.0", + "@signalapp/libsignal-client": "0.18.1", "@sindresorhus/is": "0.8.0", "@types/fabric": "4.5.3", "abort-controller": "3.0.0", @@ -190,7 +190,7 @@ "@babel/preset-typescript": "7.17.12", "@electron/fuses": "1.5.0", "@mixer/parallel-prettier": "2.0.1", - "@signalapp/mock-server": "1.5.1", + "@signalapp/mock-server": "2.0.1", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/Groups.proto b/protos/Groups.proto index f9345ed87..aed0512bd 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -101,6 +101,8 @@ message GroupChange { message ModifyMemberProfileKeyAction { bytes presentation = 1; + bytes user_id = 2; + bytes profile_key = 3; } message AddMemberPendingProfileKeyAction { @@ -113,6 +115,15 @@ message GroupChange { message PromoteMemberPendingProfileKeyAction { bytes presentation = 1; + bytes user_id = 2; + bytes profile_key = 3; + } + + message PromoteMemberPendingPniAciProfileKeyAction { + bytes presentation = 1; + bytes user_id = 2; + bytes pni = 3; + bytes profile_key = 4; } message AddMemberPendingAdminApprovalAction { @@ -200,7 +211,8 @@ message GroupChange { ModifyAnnouncementsOnlyAction modifyAnnouncementsOnly = 21; // change epoch = 3 repeated AddMemberBannedAction addMembersBanned = 22; // change epoch = 4 repeated DeleteMemberBannedAction deleteMembersBanned = 23; // change epoch = 4 - // next: 24 + repeated PromoteMemberPendingPniAciProfileKeyAction promoteMembersPendingPniAciProfileKey = 24; // change epoch = 5 + // next: 25 } bytes actions = 1; // The serialized actions diff --git a/ts/background.ts b/ts/background.ts index 2d3548269..7e33266e4 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -51,7 +51,7 @@ import { import { senderCertificateService } from './services/senderCertificate'; import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher'; import * as KeyboardLayout from './services/keyboardLayout'; -import { routineProfileRefresh } from './routineProfileRefresh'; +import { RoutineProfileRefresher } from './routineProfileRefresh'; import { isMoreRecentThan, isOlderThan, toDayMillis } from './util/timestamp'; import { isValidReactionEmoji } from './reactions/isValidReactionEmoji'; import type { ConversationModel } from './models/conversations'; @@ -220,6 +220,7 @@ export async function startApp(): Promise { let server: WebAPIType | undefined; let messageReceiver: MessageReceiver | undefined; let challengeHandler: ChallengeHandler | undefined; + let routineProfileRefresher: RoutineProfileRefresher | undefined; window.storage.onready(() => { server = window.WebAPI.connect( @@ -812,6 +813,11 @@ export async function startApp(): Promise { await window.Signal.Data.clearAllErrorStickerPackAttempts(); } + if (window.isBeforeVersion(lastVersion, 'v5.50.0-alpha.1')) { + await window.storage.put('groupCredentials', []); + await window.Signal.Data.removeAllProfileKeyCredentials(); + } + // This one should always be last - it could restart the app if (window.isBeforeVersion(lastVersion, 'v5.30.0-alpha')) { await deleteAllLogs(); @@ -1172,7 +1178,12 @@ export async function startApp(): Promise { window.Whisper.events.on('userChanged', (reconnect = false) => { const newDeviceId = window.textsecure.storage.user.getDeviceId(); const newNumber = window.textsecure.storage.user.getNumber(); - const newUuid = window.textsecure.storage.user.getUuid()?.toString(); + const newACI = window.textsecure.storage.user + .getUuid(UUIDKind.ACI) + ?.toString(); + const newPNI = window.textsecure.storage.user + .getUuid(UUIDKind.PNI) + ?.toString(); const ourConversation = window.ConversationController.getOurConversation(); @@ -1184,7 +1195,8 @@ export async function startApp(): Promise { ourConversationId: ourConversation?.get('id'), ourDeviceId: newDeviceId, ourNumber: newNumber, - ourUuid: newUuid, + ourACI: newACI, + ourPNI: newPNI, regionCode: window.storage.get('regionCode'), }); @@ -2492,14 +2504,15 @@ export async function startApp(): Promise { // Kick off a profile refresh if necessary, but don't wait for it, as failure is // tolerable. - const ourConversationId = - window.ConversationController.getOurConversationId(); - if (ourConversationId) { - routineProfileRefresh({ - allConversations: window.ConversationController.getAll(), - ourConversationId, + if (!routineProfileRefresher) { + routineProfileRefresher = new RoutineProfileRefresher({ + getAllConversations: () => window.ConversationController.getAll(), + getOurConversationId: () => + window.ConversationController.getOurConversationId(), storage, }); + + routineProfileRefresher.start(); } else { assert( false, @@ -2625,10 +2638,14 @@ export async function startApp(): Promise { return; } + const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI); + const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); + // We drop typing notifications in groups we're not a part of if ( !isDirectConversation(conversation.attributes) && - !conversation.hasMember(ourId) + !(ourACI && conversation.hasMember(ourACI)) && + !(ourPNI && conversation.hasMember(ourPNI)) ) { log.warn( `Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` diff --git a/ts/components/conversation/GroupV2Change.stories.tsx b/ts/components/conversation/GroupV2Change.stories.tsx index 71452d739..7604ff564 100644 --- a/ts/components/conversation/GroupV2Change.stories.tsx +++ b/ts/components/conversation/GroupV2Change.stories.tsx @@ -16,7 +16,8 @@ import type { FullJSXType } from '../Intl'; const i18n = setupI18n('en', enMessages); -const OUR_ID = UUID.generate().toString(); +const OUR_ACI = UUID.generate().toString(); +const OUR_PNI = UUID.generate().toString(); const CONTACT_A = UUID.generate().toString(); const CONTACT_B = UUID.generate().toString(); const CONTACT_C = UUID.generate().toString(); @@ -59,7 +60,8 @@ const renderChange = ( groupMemberships={groupMemberships} groupName={groupName} i18n={i18n} - ourUuid={OUR_ID} + ourACI={OUR_ACI} + ourPNI={OUR_PNI} renderContact={renderContact} /> ); @@ -89,7 +91,11 @@ export const Multiple = (): JSX.Element => { }, { type: 'member-add', - uuid: OUR_ID, + uuid: OUR_ACI, + }, + { + type: 'member-add', + uuid: OUR_PNI, }, { type: 'description', @@ -97,7 +103,7 @@ export const Multiple = (): JSX.Element => { }, { type: 'member-privilege', - uuid: OUR_ID, + uuid: OUR_ACI, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -110,7 +116,7 @@ export const Create = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'create', @@ -140,7 +146,7 @@ export const Title = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'title', @@ -166,7 +172,7 @@ export const Title = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'title', @@ -196,7 +202,7 @@ export const Avatar = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'avatar', @@ -222,7 +228,7 @@ export const Avatar = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'avatar', @@ -255,7 +261,7 @@ export const AccessAttributes = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'access-attributes', @@ -281,7 +287,7 @@ export const AccessAttributes = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'access-attributes', @@ -318,7 +324,7 @@ export const AccessMembers = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'access-members', @@ -344,7 +350,7 @@ export const AccessMembers = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'access-members', @@ -381,7 +387,7 @@ export const AccessInviteLink = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'access-invite-link', @@ -407,7 +413,7 @@ export const AccessInviteLink = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'access-invite-link', @@ -444,11 +450,11 @@ export const MemberAdd = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -457,7 +463,7 @@ export const MemberAdd = (): JSX.Element => { details: [ { type: 'member-add', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -465,12 +471,12 @@ export const MemberAdd = (): JSX.Element => { details: [ { type: 'member-add', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add', @@ -508,7 +514,7 @@ export const MemberAddFromInvited = (): JSX.Element => { details: [ { type: 'member-add-from-invite', - uuid: OUR_ID, + uuid: OUR_ACI, inviter: CONTACT_B, }, ], @@ -517,14 +523,14 @@ export const MemberAddFromInvited = (): JSX.Element => { details: [ { type: 'member-add-from-invite', - uuid: OUR_ID, + uuid: OUR_ACI, inviter: CONTACT_A, }, ], })} {/* the rest of the 'someone added someone else' checks */} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add-from-invite', @@ -554,21 +560,21 @@ export const MemberAddFromInvited = (): JSX.Element => { })} {/* in all of these we know the user has accepted the invite */} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add-from-invite', - uuid: OUR_ID, + uuid: OUR_ACI, inviter: CONTACT_A, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add-from-invite', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -578,7 +584,7 @@ export const MemberAddFromInvited = (): JSX.Element => { { type: 'member-add-from-invite', uuid: CONTACT_A, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} @@ -601,6 +607,17 @@ export const MemberAddFromInvited = (): JSX.Element => { }, ], })} + ACI accepts PNI invite: + {renderChange({ + from: OUR_PNI, + details: [ + { + type: 'member-add-from-invite', + uuid: OUR_ACI, + inviter: CONTACT_B, + }, + ], + })} ); }; @@ -613,11 +630,11 @@ export const MemberAddFromLink = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add-from-link', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -654,7 +671,7 @@ export const MemberAddFromAdminApproval = (): JSX.Element => { details: [ { type: 'member-add-from-admin-approval', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -662,12 +679,12 @@ export const MemberAddFromAdminApproval = (): JSX.Element => { details: [ { type: 'member-add-from-admin-approval', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-add-from-admin-approval', @@ -704,11 +721,11 @@ export const MemberRemove = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-remove', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -717,7 +734,7 @@ export const MemberRemove = (): JSX.Element => { details: [ { type: 'member-remove', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -725,12 +742,12 @@ export const MemberRemove = (): JSX.Element => { details: [ { type: 'member-remove', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-remove', @@ -776,7 +793,7 @@ export const MemberPrivilege = (): JSX.Element => { details: [ { type: 'member-privilege', - uuid: OUR_ID, + uuid: OUR_ACI, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -785,13 +802,13 @@ export const MemberPrivilege = (): JSX.Element => { details: [ { type: 'member-privilege', - uuid: OUR_ID, + uuid: OUR_ACI, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-privilege', @@ -824,7 +841,7 @@ export const MemberPrivilege = (): JSX.Element => { details: [ { type: 'member-privilege', - uuid: OUR_ID, + uuid: OUR_ACI, newPrivilege: RoleEnum.DEFAULT, }, ], @@ -833,13 +850,13 @@ export const MemberPrivilege = (): JSX.Element => { details: [ { type: 'member-privilege', - uuid: OUR_ID, + uuid: OUR_ACI, newPrivilege: RoleEnum.DEFAULT, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'member-privilege', @@ -879,7 +896,7 @@ export const PendingAddOne = (): JSX.Element => { details: [ { type: 'pending-add-one', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -887,12 +904,12 @@ export const PendingAddOne = (): JSX.Element => { details: [ { type: 'pending-add-one', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-add-one', @@ -929,7 +946,7 @@ export const PendingAddMany = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-add-many', @@ -971,17 +988,17 @@ export const PendingRemoveOne = (): JSX.Element => { { type: 'pending-remove-one', uuid: INVITEE_A, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-remove-one', uuid: INVITEE_A, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} @@ -991,7 +1008,7 @@ export const PendingRemoveOne = (): JSX.Element => { { type: 'pending-remove-one', uuid: INVITEE_A, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} @@ -1000,7 +1017,7 @@ export const PendingRemoveOne = (): JSX.Element => { { type: 'pending-remove-one', uuid: INVITEE_A, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} @@ -1029,7 +1046,7 @@ export const PendingRemoveOne = (): JSX.Element => { details: [ { type: 'pending-remove-one', - uuid: OUR_ID, + uuid: OUR_ACI, inviter: CONTACT_B, }, ], @@ -1056,7 +1073,7 @@ export const PendingRemoveOne = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-remove-one', @@ -1076,7 +1093,7 @@ export const PendingRemoveOne = (): JSX.Element => { })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-remove-one', @@ -1113,12 +1130,12 @@ export const PendingRemoveMany = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 5, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} @@ -1128,7 +1145,7 @@ export const PendingRemoveMany = (): JSX.Element => { { type: 'pending-remove-many', count: 5, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} @@ -1137,12 +1154,12 @@ export const PendingRemoveMany = (): JSX.Element => { { type: 'pending-remove-many', count: 5, - inviter: OUR_ID, + inviter: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-remove-many', @@ -1171,7 +1188,7 @@ export const PendingRemoveMany = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'pending-remove-many', @@ -1212,7 +1229,7 @@ export const AdminApprovalAdd = (): JSX.Element => { details: [ { type: 'admin-approval-add-one', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -1236,11 +1253,11 @@ export const AdminApprovalRemove = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'admin-approval-remove-one', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} @@ -1248,12 +1265,12 @@ export const AdminApprovalRemove = (): JSX.Element => { details: [ { type: 'admin-approval-remove-one', - uuid: OUR_ID, + uuid: OUR_ACI, }, ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'admin-approval-remove-one', @@ -1354,7 +1371,7 @@ export const GroupLinkAdd = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'group-link-add', @@ -1380,7 +1397,7 @@ export const GroupLinkAdd = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'group-link-add', @@ -1417,7 +1434,7 @@ export const GroupLinkReset = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'group-link-reset', @@ -1451,7 +1468,7 @@ export const GroupLinkRemove = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'group-link-remove', @@ -1485,7 +1502,7 @@ export const DescriptionRemove = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { removed: true, @@ -1523,7 +1540,7 @@ export const DescriptionChange = (): JSX.Element => { <> {renderChange( { - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'description', @@ -1571,7 +1588,7 @@ export const AnnouncementGroupChange = (): JSX.Element => { return ( <> {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'announcements-only', @@ -1597,7 +1614,7 @@ export const AnnouncementGroupChange = (): JSX.Element => { ], })} {renderChange({ - from: OUR_ID, + from: OUR_ACI, details: [ { type: 'announcements-only', diff --git a/ts/components/conversation/GroupV2Change.tsx b/ts/components/conversation/GroupV2Change.tsx index f50fe30b4..611c42af2 100644 --- a/ts/components/conversation/GroupV2Change.tsx +++ b/ts/components/conversation/GroupV2Change.tsx @@ -30,7 +30,8 @@ export type PropsDataType = { }>; groupBannedMemberships?: Array; groupName?: string; - ourUuid?: UUIDStringType; + ourACI?: UUIDStringType; + ourPNI?: UUIDStringType; change: GroupV2ChangeType; }; @@ -132,7 +133,8 @@ function GroupV2Detail({ groupBannedMemberships, groupName, i18n, - ourUuid, + ourACI, + ourPNI, renderContact, text, }: { @@ -148,7 +150,8 @@ function GroupV2Detail({ groupName?: string; i18n: LocalizerType; fromId?: UUIDStringType; - ourUuid?: UUIDStringType; + ourACI?: UUIDStringType; + ourPNI?: UUIDStringType; renderContact: SmartContactRendererType; text: FullJSXType; }): JSX.Element { @@ -241,7 +244,8 @@ function GroupV2Detail({ detail.type === 'admin-approval-bounce' && areWeAdmin && detail.uuid && - detail.uuid !== ourUuid && + detail.uuid !== ourACI && + detail.uuid !== ourPNI && (!fromId || fromId === detail.uuid) && !groupMemberships?.some(item => item.uuid === detail.uuid) && !groupBannedMemberships?.some(uuid => uuid === detail.uuid) @@ -276,7 +280,8 @@ export function GroupV2Change(props: PropsType): ReactElement { groupMemberships, groupName, i18n, - ourUuid, + ourACI, + ourPNI, renderContact, } = props; @@ -284,7 +289,8 @@ export function GroupV2Change(props: PropsType): ReactElement { <> {renderChange(change, { i18n, - ourUuid, + ourACI, + ourPNI, renderContact, renderString: renderStringToIntl, }).map(({ detail, isLastText, text }, index) => { @@ -302,7 +308,8 @@ export function GroupV2Change(props: PropsType): ReactElement { // Difficult to find a unique key for this type // eslint-disable-next-line react/no-array-index-key key={index} - ourUuid={ourUuid} + ourACI={ourACI} + ourPNI={ourPNI} renderContact={renderContact} text={text} /> diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index ffe594737..c9e00163b 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -115,9 +115,9 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ contact => contact.username === username ); - isUsernameVisible = candidateContacts.every( - contact => contact.username !== username - ); + isUsernameVisible = + Boolean(username) && + candidateContacts.every(contact => contact.username !== username); } const inputRef = useRef(null); diff --git a/ts/groupChange.ts b/ts/groupChange.ts index 640b3087a..0dc2d9e89 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -20,7 +20,8 @@ export type StringRendererType = ( export type RenderOptionsType = { from?: UUIDStringType; i18n: LocalizerType; - ourUuid?: UUIDStringType; + ourACI?: UUIDStringType; + ourPNI?: UUIDStringType; renderContact: SmartContactRendererType; renderString: StringRendererType; }; @@ -66,8 +67,15 @@ export function renderChangeDetail( detail: GroupV2ChangeDetailType, options: RenderOptionsType ): T | string | ReadonlyArray { - const { from, i18n, ourUuid, renderContact, renderString } = options; - const fromYou = Boolean(from && ourUuid && from === ourUuid); + const { from, i18n, ourACI, ourPNI, renderContact, renderString } = options; + + const isOurUuid = (uuid?: UUIDStringType): boolean => { + if (!uuid) { + return false; + } + return Boolean((ourACI && uuid === ourACI) || (ourPNI && uuid === ourPNI)); + }; + const fromYou = isOurUuid(from); if (detail.type === 'create') { if (fromYou) { @@ -229,7 +237,7 @@ export function renderChangeDetail( } if (detail.type === 'member-add') { const { uuid } = detail; - const weAreJoiner = Boolean(ourUuid && uuid === ourUuid); + const weAreJoiner = isOurUuid(uuid); if (weAreJoiner) { if (fromYou) { @@ -259,10 +267,11 @@ export function renderChangeDetail( } if (detail.type === 'member-add-from-invite') { const { uuid, inviter } = detail; - const weAreJoiner = Boolean(ourUuid && uuid === ourUuid); - const weAreInviter = Boolean(inviter && ourUuid && inviter === ourUuid); + const weAreJoiner = isOurUuid(uuid); + const weAreInviter = isOurUuid(inviter); + const pniPromotedToACI = weAreJoiner && from === ourPNI; - if (!from || from !== uuid) { + if (!from || (from !== uuid && !pniPromotedToACI)) { if (weAreJoiner) { // They can't be the same, no fromYou check here if (from) { @@ -322,7 +331,7 @@ export function renderChangeDetail( if (detail.type === 'member-add-from-link') { const { uuid } = detail; - if (fromYou && ourUuid && uuid === ourUuid) { + if (fromYou && isOurUuid(uuid)) { return renderString('GroupV2--member-add-from-link--you--you', i18n); } if (from && uuid === from) { @@ -340,7 +349,7 @@ export function renderChangeDetail( } if (detail.type === 'member-add-from-admin-approval') { const { uuid } = detail; - const weAreJoiner = Boolean(ourUuid && uuid === ourUuid); + const weAreJoiner = isOurUuid(uuid); if (weAreJoiner) { if (from) { @@ -391,7 +400,7 @@ export function renderChangeDetail( } if (detail.type === 'member-remove') { const { uuid } = detail; - const weAreLeaver = Boolean(ourUuid && uuid === ourUuid); + const weAreLeaver = isOurUuid(uuid); if (weAreLeaver) { if (fromYou) { @@ -427,7 +436,7 @@ export function renderChangeDetail( } if (detail.type === 'member-privilege') { const { uuid, newPrivilege } = detail; - const weAreMember = Boolean(ourUuid && uuid === ourUuid); + const weAreMember = isOurUuid(uuid); if (newPrivilege === RoleEnum.ADMINISTRATOR) { if (weAreMember) { @@ -513,7 +522,7 @@ export function renderChangeDetail( } if (detail.type === 'pending-add-one') { const { uuid } = detail; - const weAreInvited = Boolean(ourUuid && uuid === ourUuid); + const weAreInvited = isOurUuid(uuid); if (weAreInvited) { if (from) { return renderString('GroupV2--pending-add--one--you--other', i18n, [ @@ -554,8 +563,8 @@ export function renderChangeDetail( } if (detail.type === 'pending-remove-one') { const { inviter, uuid } = detail; - const weAreInviter = Boolean(inviter && ourUuid && inviter === ourUuid); - const weAreInvited = Boolean(ourUuid && uuid === ourUuid); + const weAreInviter = isOurUuid(inviter); + const weAreInvited = isOurUuid(uuid); const sentByInvited = Boolean(from && from === uuid); const sentByInviter = Boolean(from && inviter && from === inviter); @@ -649,7 +658,7 @@ export function renderChangeDetail( } if (detail.type === 'pending-remove-many') { const { count, inviter } = detail; - const weAreInviter = Boolean(inviter && ourUuid && inviter === ourUuid); + const weAreInviter = isOurUuid(inviter); if (weAreInviter) { if (fromYou) { @@ -729,7 +738,7 @@ export function renderChangeDetail( } if (detail.type === 'admin-approval-add-one') { const { uuid } = detail; - const weAreJoiner = Boolean(ourUuid && uuid === ourUuid); + const weAreJoiner = isOurUuid(uuid); if (weAreJoiner) { return renderString('GroupV2--admin-approval-add-one--you', i18n); @@ -740,7 +749,7 @@ export function renderChangeDetail( } if (detail.type === 'admin-approval-remove-one') { const { uuid } = detail; - const weAreJoiner = Boolean(ourUuid && uuid === ourUuid); + const weAreJoiner = isOurUuid(uuid); if (weAreJoiner) { if (fromYou) { diff --git a/ts/groups.ts b/ts/groups.ts index c684016bf..32c5f42ec 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -16,8 +16,7 @@ import LRU from 'lru-cache'; import PQueue from 'p-queue'; import * as log from './logging/log'; import { - getCredentialsForToday, - GROUP_CREDENTIALS_KEY, + getCheckedCredentialsForToday, maybeFetchNewCredentials, } from './services/groupCredentialFetcher'; import dataInterface from './sql/Client'; @@ -37,9 +36,10 @@ import type { } from './model-types.d'; import { createProfileKeyCredentialPresentation, + createPNICredentialPresentation, + decodeProfileKeyCredentialPresentation, decryptGroupBlob, decryptProfileKey, - decryptProfileKeyCredentialPresentation, decryptUuid, deriveGroupID, deriveGroupPublicParams, @@ -72,7 +72,7 @@ import { } from './util/whatTypeOfConversation'; import * as Bytes from './Bytes'; import type { AvatarDataType } from './types/Avatar'; -import { UUID, isValidUuid } from './types/UUID'; +import { UUID, UUIDKind, isValidUuid } from './types/UUID'; import type { UUIDStringType } from './types/UUID'; import * as Errors from './types/errors'; import { SignalService as Proto } from './protobuf'; @@ -314,7 +314,7 @@ export const ID_LENGTH = 32; const TEMPORAL_AUTH_REJECTED_CODE = 401; const GROUP_ACCESS_DENIED_CODE = 403; const GROUP_NONEXISTENT_CODE = 404; -const SUPPORTED_CHANGE_EPOCH = 4; +const SUPPORTED_CHANGE_EPOCH = 5; export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR'; const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16; @@ -593,12 +593,9 @@ function buildGroupProto( return member; }); - const ourUuid = window.storage.user.getCheckedUuid(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); - const ourUuidCipherTextBuffer = encryptUuid( - clientZkGroupCipher, - ourUuid.toString() - ); + const ourACICipherTextBuffer = encryptUuid(clientZkGroupCipher, ourACI); proto.membersPendingProfileKey = (attributes.pendingMembersV2 || []).map( item => { @@ -610,10 +607,9 @@ function buildGroupProto( throw new Error('buildGroupProto: no conversation for pending member!'); } - const uuid = conversation.get('uuid'); - if (!uuid) { - throw new Error('buildGroupProto: pending member was missing uuid!'); - } + const uuid = conversation.getCheckedUuid( + 'buildGroupProto: pending member was missing uuid!' + ); const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); member.userId = uuidCipherTextBuffer; @@ -621,7 +617,7 @@ function buildGroupProto( pendingMember.member = member; pendingMember.timestamp = Long.fromNumber(item.timestamp); - pendingMember.addedByUserId = ourUuidCipherTextBuffer; + pendingMember.addedByUserId = ourACICipherTextBuffer; return pendingMember; } @@ -661,11 +657,8 @@ export async function buildAddMembersChange( ); const clientZkGroupCipher = getClientZkGroupCipher(secretParams); - const ourUuid = window.storage.user.getCheckedUuid(); - const ourUuidCipherTextBuffer = encryptUuid( - clientZkGroupCipher, - ourUuid.toString() - ); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourACICipherTextBuffer = encryptUuid(clientZkGroupCipher, ourACI); const now = Date.now(); @@ -685,7 +678,7 @@ export async function buildAddMembersChange( return; } - const uuid = contact.get('uuid'); + const uuid = contact.getUuid(); if (!uuid) { assert(false, `buildAddMembersChange/${logId}: missing UUID; skipping`); return; @@ -722,7 +715,7 @@ export async function buildAddMembersChange( } else { const memberPendingProfileKey = new Proto.MemberPendingProfileKey(); memberPendingProfileKey.member = member; - memberPendingProfileKey.addedByUserId = ourUuidCipherTextBuffer; + memberPendingProfileKey.addedByUserId = ourACICipherTextBuffer; memberPendingProfileKey.timestamp = Long.fromNumber(now); const addPendingMemberAction = @@ -733,7 +726,7 @@ export async function buildAddMembersChange( } const doesMemberNeedUnban = conversation.bannedMembersV2?.find( - bannedMember => bannedMember.uuid === uuid + bannedMember => bannedMember.uuid === uuid.toString() ); if (doesMemberNeedUnban) { const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); @@ -991,15 +984,15 @@ export function _maybeBuildAddBannedMemberActions({ }: { clientZkGroupCipher: ClientZkGroupCipher; group: Pick; - ourUuid: UUIDStringType; - uuid: UUIDStringType; + ourUuid: UUID; + uuid: UUID; }): Pick< Proto.GroupChange.IActions, 'addMembersBanned' | 'deleteMembersBanned' > { const doesMemberNeedBan = - !group.bannedMembersV2?.find(member => member.uuid === uuid) && - uuid !== ourUuid; + !group.bannedMembersV2?.find(member => member.uuid === uuid.toString()) && + !uuid.isEqual(ourUuid); if (!doesMemberNeedBan) { return {}; } @@ -1024,7 +1017,7 @@ export function _maybeBuildAddBannedMemberActions({ deleteMemberBannedAction.deletedUserId = encryptUuid( clientZkGroupCipher, - bannedMember.uuid + new UUID(bannedMember.uuid) ); return deleteMemberBannedAction; @@ -1051,8 +1044,8 @@ export function buildDeletePendingAdminApprovalMemberChange({ uuid, }: { group: ConversationAttributesType; - ourUuid: UUIDStringType; - uuid: UUIDStringType; + ourUuid: UUID; + uuid: UUID; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1140,7 +1133,7 @@ export function buildAddMember({ profileKeyCredentialBase64: string; serverPublicParamsBase64: string; joinFromInviteLink?: boolean; - uuid: UUIDStringType; + uuid: UUID; }): Proto.GroupChange.Actions { const MEMBER_ROLE_ENUM = Proto.Member.Role; @@ -1170,7 +1163,7 @@ export function buildAddMember({ actions.addMembers = [addMember]; const doesMemberNeedUnban = group.bannedMembersV2?.find( - member => member.uuid === uuid + member => member.uuid === uuid.toString() ); if (doesMemberNeedUnban) { const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); @@ -1190,7 +1183,7 @@ export function buildDeletePendingMemberChange({ uuids, group, }: { - uuids: Array; + uuids: Array; group: ConversationAttributesType; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1222,8 +1215,8 @@ export function buildDeleteMemberChange({ uuid, }: { group: ConversationAttributesType; - ourUuid: UUIDStringType; - uuid: UUIDStringType; + ourUuid: UUID; + uuid: UUID; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1261,7 +1254,7 @@ export function buildAddBannedMemberChange({ uuid, group, }: { - uuid: UUIDStringType; + uuid: UUID; group: ConversationAttributesType; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1282,7 +1275,9 @@ export function buildAddBannedMemberChange({ actions.addMembersBanned = [addMemberBannedAction]; - if (group.pendingAdminApprovalV2?.some(item => item.uuid === uuid)) { + if ( + group.pendingAdminApprovalV2?.some(item => item.uuid === uuid.toString()) + ) { const deleteMemberPendingAdminApprovalAction = new Proto.GroupChange.Actions.DeleteMemberPendingAdminApprovalAction(); @@ -1303,7 +1298,7 @@ export function buildModifyMemberRoleChange({ group, role, }: { - uuid: UUIDStringType; + uuid: UUID; group: ConversationAttributesType; role: number; }): Proto.GroupChange.Actions { @@ -1331,7 +1326,7 @@ export function buildPromotePendingAdminApprovalMemberChange({ uuid, }: { group: ConversationAttributesType; - uuid: UUIDStringType; + uuid: UUID; }): Proto.GroupChange.Actions { const MEMBER_ROLE_ENUM = Proto.Member.Role; const actions = new Proto.GroupChange.Actions(); @@ -1356,15 +1351,19 @@ export function buildPromotePendingAdminApprovalMemberChange({ return actions; } +export type BuildPromoteMemberChangeOptionsType = Readonly<{ + group: ConversationAttributesType; + serverPublicParamsBase64: string; + profileKeyCredentialBase64?: string; + pniCredentialBase64?: string; +}>; + export function buildPromoteMemberChange({ group, profileKeyCredentialBase64, + pniCredentialBase64, serverPublicParamsBase64, -}: { - group: ConversationAttributesType; - profileKeyCredentialBase64: string; - serverPublicParamsBase64: string; -}): Proto.GroupChange.Actions { +}: BuildPromoteMemberChangeOptionsType): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); if (!group.secretParams) { @@ -1372,27 +1371,48 @@ export function buildPromoteMemberChange({ 'buildDisappearingMessagesTimerChange: group was missing secretParams!' ); } + + actions.version = (group.revision || 0) + 1; + const clientZkProfileCipher = getClientZkProfileOperations( serverPublicParamsBase64 ); - const presentation = createProfileKeyCredentialPresentation( - clientZkProfileCipher, - profileKeyCredentialBase64, - group.secretParams - ); + let presentation: Uint8Array; + if (profileKeyCredentialBase64 !== undefined) { + presentation = createProfileKeyCredentialPresentation( + clientZkProfileCipher, + profileKeyCredentialBase64, + group.secretParams + ); - const promotePendingMember = - new Proto.GroupChange.Actions.PromoteMemberPendingProfileKeyAction(); - promotePendingMember.presentation = presentation; + actions.promotePendingMembers = [ + { + presentation, + }, + ]; + } else { + strictAssert( + pniCredentialBase64, + 'Either pniCredential or profileKeyCredential must be present' + ); + presentation = createPNICredentialPresentation( + clientZkProfileCipher, + pniCredentialBase64, + group.secretParams + ); - actions.version = (group.revision || 0) + 1; - actions.promotePendingMembers = [promotePendingMember]; + actions.promoteMembersPendingPniAciProfileKey = [ + { + presentation, + }, + ]; + } return actions; } -export async function uploadGroupChange({ +async function uploadGroupChange({ actions, group, inviteLinkPassword, @@ -1424,12 +1444,14 @@ export async function uploadGroupChange({ export async function modifyGroupV2({ conversation, + usingCredentialsFrom, createGroupChange, extraConversationsForSend, inviteLinkPassword, name, }: { conversation: ConversationModel; + usingCredentialsFrom: ReadonlyArray; createGroupChange: () => Promise; extraConversationsForSend?: Array; inviteLinkPassword?: string; @@ -1448,6 +1470,8 @@ export async function modifyGroupV2({ const MAX_ATTEMPTS = 5; + let refreshedCredentials = false; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { log.info(`modifyGroupV2/${logId}: Starting attempt ${attempt}`); try { @@ -1480,7 +1504,7 @@ export async function modifyGroupV2({ } // Upload. If we don't have permission, the server will return an error here. - const groupChange = await window.Signal.Groups.uploadGroupChange({ + const groupChange = await uploadGroupChange({ actions, inviteLinkPassword, group: conversation.attributes, @@ -1529,6 +1553,32 @@ export async function modifyGroupV2({ // eslint-disable-next-line no-await-in-loop await conversation.fetchLatestGroupV2Data({ force: true }); + } else if (error.code === 400 && !refreshedCredentials) { + const logIds = usingCredentialsFrom.map(member => + member.idForLogging() + ); + log.warn( + `modifyGroupV2/${logId}: Profile key credentials were not ` + + `up-to-date. Updating profiles for ${logIds} and retrying` + ); + + for (const member of usingCredentialsFrom) { + member.set({ + profileKeyCredential: null, + profileKeyCredentialExpiration: null, + }); + } + + const profileFetchQueue = new PQueue({ + concurrency: 3, + }); + // eslint-disable-next-line no-await-in-loop + await profileFetchQueue.addAll( + usingCredentialsFrom.map(member => () => member.getProfiles()) + ); + + // Fetch credentials only once + refreshedCredentials = true; } else if (error.code === 409) { log.error( `modifyGroupV2/${logId}: Conflict while updating. Timed out; not retrying.` @@ -1537,7 +1587,7 @@ export async function modifyGroupV2({ conversation.fetchLatestGroupV2Data({ force: true }); throw error; } else { - const errorString = error && error.stack ? error.stack : error; + const errorString = Errors.toLogFormat(error); log.error(`modifyGroupV2/${logId}: Error updating: ${errorString}`); throw error; } @@ -1591,13 +1641,9 @@ async function makeRequestWithTemporalRetry({ secretParams: string; request: (sender: MessageSender, options: GroupCredentialsType) => Promise; }): Promise { - const data = window.storage.get(GROUP_CREDENTIALS_KEY); - if (!data) { - throw new Error( - `makeRequestWithTemporalRetry/${logId}: No group credentials!` - ); - } - const groupCredentials = getCredentialsForToday(data); + const groupCredentials = getCheckedCredentialsForToday( + `makeRequestWithTemporalRetry/${logId}` + ); const sender = window.textsecure.messaging; if (!sender) { @@ -1606,6 +1652,8 @@ async function makeRequestWithTemporalRetry({ ); } + log.info(`makeRequestWithTemporalRetry/${logId}: starting`); + const todayOptions = getGroupCredentials({ authCredentialBase64: groupCredentials.today.credential, groupPublicParamsBase64: publicParams, @@ -1691,11 +1739,11 @@ export async function createGroupV2({ const secretParams = Bytes.toBase64(fields.secretParams); const publicParams = Bytes.toBase64(fields.publicParams); - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI).toString(); const membersV2: Array = [ { - uuid: ourUuid, + uuid: ourACI, role: MEMBER_ROLE_ENUM.ADMINISTRATOR, joinedAtVersion: 0, }, @@ -1734,7 +1782,7 @@ export async function createGroupV2({ }); } else { pendingMembersV2.push({ - addedByUserId: ourUuid, + addedByUserId: ourACI, uuid: contactUuid, timestamp: Date.now(), role: MEMBER_ROLE_ENUM.DEFAULT, @@ -1817,7 +1865,7 @@ export async function createGroupV2({ { ...protoAndConversationAttributes, active_at: now, - addedBy: ourUuid, + addedBy: ourACI, avatar: avatarAttribute, avatars, groupVersion: 2, @@ -1848,7 +1896,7 @@ export async function createGroupV2({ const createdTheGroupMessage: MessageAttributesType = { ...generateBasicMessage(), type: 'group-v2-change', - sourceUuid: ourUuid, + sourceUuid: ourACI, conversationId: conversation.id, readStatus: ReadStatus.Read, received_at: window.Signal.Util.incrementMessageCounter(), @@ -1857,13 +1905,13 @@ export async function createGroupV2({ seenStatus: SeenStatus.Seen, sent_at: timestamp, groupV2Change: { - from: ourUuid, + from: ourACI, details: [{ type: 'create' }], }, }; await dataInterface.saveMessages([createdTheGroupMessage], { forceSave: true, - ourUuid, + ourUuid: ourACI, }); const model = new window.Whisper.Message(createdTheGroupMessage); window.MessageController.register(model.id, model); @@ -1959,9 +2007,9 @@ export async function isGroupEligibleToMigrate( return false; } - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); const areWeMember = - !conversation.get('left') && conversation.hasMember(ourUuid); + !conversation.get('left') && conversation.hasMember(ourACI); if (!areWeMember) { return false; } @@ -2001,7 +2049,7 @@ export async function getGroupMigrationMembers( ); } - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI).toString(); let areWeMember = false; let areWeInvited = false; @@ -2132,7 +2180,7 @@ export async function getGroupMigrationMembers( return { uuid: contactUuid, timestamp: now, - addedByUserId: ourUuid, + addedByUserId: ourACI, role: MEMBER_ROLE_ENUM.ADMINISTRATOR, }; }) @@ -2378,7 +2426,8 @@ export function buildMigrationBubble( previousGroupV1MembersIds: Array, newAttributes: ConversationAttributesType ): GroupChangeMessageType { - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.storage.user.getUuid(UUIDKind.PNI); const ourConversationId = window.ConversationController.getOurConversationId(); @@ -2398,11 +2447,15 @@ export function buildMigrationBubble( combinedConversationIds ).filter(id => id && id !== ourConversationId); const invitedMembers = (newAttributes.pendingMembersV2 || []).filter( - item => item.uuid !== ourUuid + item => + item.uuid !== ourACI.toString() && + !(ourPNI && item.uuid === ourPNI.toString()) ); const areWeInvited = (newAttributes.pendingMembersV2 || []).some( - item => item.uuid === ourUuid + item => + item.uuid === ourACI.toString() || + (ourPNI && item.uuid === ourPNI.toString()) ); return { @@ -2539,8 +2592,8 @@ export async function respondToGroupV2Migration({ ); } - const ourUuid = window.storage.user.getCheckedUuid().toString(); - const wereWePreviouslyAMember = conversation.hasMember(ourUuid); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); + const wereWePreviouslyAMember = conversation.hasMember(ourACI); // Derive GroupV2 fields const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id); @@ -2643,7 +2696,7 @@ export async function respondToGroupV2Migration({ addedBy: undefined, left: true, members: (conversation.get('members') || []).filter( - item => item !== ourUuid && item !== ourNumber + item => item !== ourACI.toString() && item !== ourNumber ), }, groupChangeMessages: [ @@ -2659,7 +2712,7 @@ export async function respondToGroupV2Migration({ details: [ { type: 'member-remove' as const, - uuid: ourUuid, + uuid: ourACI.toString(), }, ], }, @@ -2726,10 +2779,10 @@ export async function respondToGroupV2Migration({ }); const areWeInvited = (newAttributes.pendingMembersV2 || []).some( - item => item.uuid === ourUuid + item => item.uuid === ourACI.toString() ); const areWeMember = (newAttributes.membersV2 || []).some( - item => item.uuid === ourUuid + item => item.uuid === ourACI.toString() ); if (!areWeInvited && !areWeMember) { // Add a message to the timeline saying the user was removed. This shouldn't happen. @@ -2740,7 +2793,7 @@ export async function respondToGroupV2Migration({ details: [ { type: 'member-remove' as const, - uuid: ourUuid, + uuid: ourACI.toString(), }, ], }, @@ -2897,19 +2950,27 @@ async function updateGroup( const logId = conversation.idForLogging(); const { newAttributes, groupChangeMessages, members } = updates; - const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); + const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); const startingRevision = conversation.get('revision'); const endingRevision = newAttributes.revision; const wasMemberOrPending = - conversation.hasMember(ourUuid) || conversation.isMemberPending(ourUuid); + conversation.hasMember(ourACI) || + conversation.isMemberPending(ourACI) || + (ourPNI && conversation.isMemberPending(ourPNI)); const isMemberOrPending = !newAttributes.left || - newAttributes.pendingMembersV2?.some(item => item.uuid === ourUuid); + newAttributes.pendingMembersV2?.some( + item => + item.uuid === ourACI.toString() || item.uuid === ourPNI?.toString() + ); const isMemberOrPendingOrAwaitingApproval = isMemberOrPending || - newAttributes.pendingAdminApprovalV2?.some(item => item.uuid === ourUuid); + newAttributes.pendingAdminApprovalV2?.some( + item => item.uuid === ourACI.toString() + ); const isInitialDataFetch = !isNumber(startingRevision) && isNumber(endingRevision); @@ -3030,8 +3091,10 @@ async function updateGroup( // If we've been added by a blocked contact, then schedule a task to leave group const justAdded = !wasMemberOrPending && isMemberOrPending; const addedBy = - newAttributes.pendingMembersV2?.find(item => item.uuid === ourUuid) - ?.addedByUserId || newAttributes.addedBy; + newAttributes.pendingMembersV2?.find( + item => + item.uuid === ourACI.toString() || item.uuid === ourPNI?.toString() + )?.addedByUserId || newAttributes.addedBy; if (justAdded && addedBy) { const adder = window.ConversationController.get(addedBy); @@ -3173,7 +3236,7 @@ async function appendChangeMessages( `appendChangeMessages/${logId}: processing ${messages.length} messages` ); - const ourUuid = window.textsecure.storage.user.getCheckedUuid(); + const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); let lastMessage = await dataInterface.getLastConversationMessage({ conversationId: conversation.id, @@ -3212,7 +3275,7 @@ async function appendChangeMessages( log.info(`appendChangeMessages/${logId}: updating ${first.id}`); await dataInterface.saveMessage(first, { - ourUuid: ourUuid.toString(), + ourUuid: ourACI.toString(), // We don't use forceSave here because this is an update of existing // message. @@ -3222,7 +3285,7 @@ async function appendChangeMessages( `appendChangeMessages/${logId}: saving ${rest.length} new messages` ); await dataInterface.saveMessages(rest, { - ourUuid: ourUuid.toString(), + ourUuid: ourACI.toString(), forceSave: true, }); } else { @@ -3230,7 +3293,7 @@ async function appendChangeMessages( `appendChangeMessages/${logId}: saving ${mergedMessages.length} new messages` ); await dataInterface.saveMessages(mergedMessages, { - ourUuid: ourUuid.toString(), + ourUuid: ourACI.toString(), forceSave: true, }); } @@ -3283,11 +3346,11 @@ async function getGroupUpdates({ const currentRevision = group.revision; const isFirstFetch = !isNumber(group.revision); - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); const isInitialCreationMessage = isFirstFetch && newRevision === 0; const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find( - item => item.uuid === ourUuid + item => item.uuid === ourACI.toString() ); const isOneVersionUp = isNumber(currentRevision) && @@ -3433,11 +3496,9 @@ async function updateGroupViaPreJoinInfo({ group: ConversationAttributesType; }): Promise { const logId = idForLogging(group.groupId); - const data = window.storage.get(GROUP_CREDENTIALS_KEY); - if (!data) { - throw new Error('updateGroupViaPreJoinInfo: No group credentials!'); - } - const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); + const ourACI = window.textsecure.storage.user + .getCheckedUuid(UUIDKind.ACI) + .toString(); const { publicParams, secretParams } = group; if (!secretParams) { @@ -3482,7 +3543,7 @@ async function updateGroupViaPreJoinInfo({ pendingMembersV2: [], pendingAdminApprovalV2: [ { - uuid: ourUuid, + uuid: ourACI, timestamp: Date.now(), }, ], @@ -3629,6 +3690,7 @@ async function updateGroupViaLogs({ logId: `getGroupLog/${logId}`, publicParams, secretParams, + // eslint-disable-next-line no-loop-func request: (sender, requestOptions) => sender.getGroupLog( @@ -3667,7 +3729,8 @@ async function generateLeftGroupChanges( ): Promise { const logId = idForLogging(group.groupId); log.info(`generateLeftGroupChanges/${logId}: Starting...`); - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI).toString(); + const ourPNI = window.storage.user.getCheckedUuid(UUIDKind.PNI)?.toString(); const { masterKey, groupInviteLinkPassword } = group; let { revision } = group; @@ -3694,14 +3757,12 @@ async function generateLeftGroupChanges( const newAttributes: ConversationAttributesType = { ...group, addedBy: undefined, - membersV2: (group.membersV2 || []).filter( - member => member.uuid !== ourUuid - ), + membersV2: (group.membersV2 || []).filter(member => member.uuid !== ourACI), pendingMembersV2: (group.pendingMembersV2 || []).filter( - member => member.uuid !== ourUuid + member => member.uuid !== ourACI && member.uuid !== ourPNI ), pendingAdminApprovalV2: (group.pendingAdminApprovalV2 || []).filter( - member => member.uuid !== ourUuid + member => member.uuid !== ourACI ), left: true, revision, @@ -3863,9 +3924,12 @@ async function integrateGroupChange({ } const isFirstFetch = !isNumber(group.revision); - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.storage.user.getUuid(UUIDKind.PNI); const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find( - item => item.uuid === ourUuid + item => + item.uuid === ourACI.toString() || + (ourPNI && item.uuid === ourPNI.toString()) ); // These need to be populated from the groupChange. But we might not get one! @@ -3932,7 +3996,7 @@ async function integrateGroupChange({ if (groupChangeActions.version === group.revision) { isSameVersion = true; } else if ( - groupChangeActions.version > group.revision + 1 || + groupChangeActions.version !== group.revision + 1 || (!isNumber(group.revision) && groupChangeActions.version > 0) ) { isMoreThanOneVersionUp = true; @@ -4063,11 +4127,12 @@ function extractDiffs({ }): Array { const logId = idForLogging(old.groupId); const details: Array = []; - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.storage.user.getUuid(UUIDKind.PNI); const ACCESS_ENUM = Proto.AccessControl.AccessRequired; let areWeInGroup = false; - let areWeInvitedToGroup = false; + let uuidKindInvitedToGroup: UUIDKind | undefined; let areWePendingApproval = false; let whoInvitedUsUserId = null; @@ -4183,17 +4248,24 @@ function extractDiffs({ UUIDStringType, GroupV2PendingAdminApprovalType >((old.pendingAdminApprovalV2 || []).map(member => [member.uuid, member])); + const currentPendingMemberSet = new Set( + (current.pendingMembersV2 || []).map(member => member.uuid) + ); (current.membersV2 || []).forEach(currentMember => { const { uuid } = currentMember; + const isUs = uuid === ourACI.toString(); - if (uuid === ourUuid) { + if (isUs) { areWeInGroup = true; } const oldMember = oldMemberLookup.get(uuid); if (!oldMember) { - const pendingMember = oldPendingMemberLookup.get(uuid); + let pendingMember = oldPendingMemberLookup.get(uuid); + if (isUs && ourPNI && !pendingMember) { + pendingMember = oldPendingMemberLookup.get(ourPNI.toString()); + } if (pendingMember) { details.push({ type: 'member-add-from-invite', @@ -4235,6 +4307,20 @@ function extractDiffs({ // This deletion makes it easier to capture removals oldMemberLookup.delete(uuid); + + // Our ACI just joined (wasn't a member before) and our PNI disappeared + // from the invite list. Treat this as a promotion from PNI to ACI and + // pretend that the PNI wasn't pending so that we won't generate a + // pending-add-one notification below. + if ( + isUs && + ourPNI && + !oldMember && + oldPendingMemberLookup.has(ourPNI.toString()) && + !currentPendingMemberSet.has(ourPNI.toString()) + ) { + oldPendingMemberLookup.delete(ourPNI.toString()); + } }); const removedMemberIds = Array.from(oldMemberLookup.keys()); @@ -4253,8 +4339,13 @@ function extractDiffs({ const { uuid } = currentPendingMember; const oldPendingMember = oldPendingMemberLookup.get(uuid); - if (uuid === ourUuid) { - areWeInvitedToGroup = true; + if (uuid === ourACI.toString() || uuid === ourPNI?.toString()) { + if (uuid === ourACI.toString()) { + uuidKindInvitedToGroup = UUIDKind.ACI; + } else if (uuidKindInvitedToGroup === undefined) { + uuidKindInvitedToGroup = UUIDKind.PNI; + } + whoInvitedUsUserId = currentPendingMember.addedByUserId; } @@ -4323,7 +4414,7 @@ function extractDiffs({ const { uuid } = currentPendingAdminAprovalMember; const oldPendingMember = oldPendingAdminApprovalLookup.get(uuid); - if (uuid === ourUuid) { + if (uuid === ourACI.toString()) { areWePendingApproval = true; } @@ -4368,12 +4459,12 @@ function extractDiffs({ let timerNotification: GroupChangeMessageType | undefined; const firstUpdate = !isNumber(old.revision); - const isFromUs = ourUuid === sourceUuid; + const isFromUs = ourACI.toString() === sourceUuid; // Here we hardcode initial messages if this is our first time processing data this // group. Ideally we can collapse it down to just one of: 'you were added', // 'you were invited', or 'you created.' - if (firstUpdate && areWeInvitedToGroup) { + if (firstUpdate && uuidKindInvitedToGroup !== undefined) { // Note, we will add 'you were invited' to group even if dropInitialJoinMessage = true message = { ...generateBasicMessage(), @@ -4383,7 +4474,9 @@ function extractDiffs({ details: [ { type: 'pending-add-one', - uuid: ourUuid, + uuid: window.storage.user + .getCheckedUuid(uuidKindInvitedToGroup) + .toString(), }, ], }, @@ -4395,11 +4488,11 @@ function extractDiffs({ ...generateBasicMessage(), type: 'group-v2-change', groupV2Change: { - from: ourUuid, + from: ourACI.toString(), details: [ { type: 'admin-approval-add-one', - uuid: ourUuid, + uuid: ourACI.toString(), }, ], }, @@ -4410,8 +4503,7 @@ function extractDiffs({ } else if ( firstUpdate && current.revision === 0 && - sourceUuid && - sourceUuid === ourUuid + sourceUuid === ourACI.toString() ) { message = { ...generateBasicMessage(), @@ -4436,7 +4528,7 @@ function extractDiffs({ details: [ { type: 'member-add', - uuid: ourUuid, + uuid: ourACI.toString(), }, ], }, @@ -4529,7 +4621,7 @@ async function applyGroupChange({ sourceUuid: UUIDStringType; }): Promise { const logId = idForLogging(group.groupId); - const ourUuid = window.storage.user.getUuid()?.toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI).toString(); const ACCESS_ENUM = Proto.AccessControl.AccessRequired; const MEMBER_ROLE_ENUM = Proto.Member.Role; @@ -4589,7 +4681,7 @@ async function applyGroupChange({ } // Capture who added us - if (ourUuid && sourceUuid && addedUuid === ourUuid) { + if (ourACI && sourceUuid && addedUuid === ourACI) { result.addedBy = sourceUuid; } @@ -4760,6 +4852,48 @@ async function applyGroupChange({ }); }); + // promoteMembersPendingPniAciProfileKey?: Array< + // GroupChange.Actions.PromoteMemberPendingPniAciProfileKeyAction + // >; + (actions.promoteMembersPendingPniAciProfileKey || []).forEach( + promotePendingMember => { + const { profileKey, aci, pni } = promotePendingMember; + if (!profileKey || !aci || !pni) { + throw new Error( + 'applyGroupChange: promotePendingMember had a missing value' + ); + } + + const previousRecord = pendingMembers[pni]; + + if (pendingMembers[pni]) { + delete pendingMembers[pni]; + } else { + log.warn( + `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was not in pendingMembers.` + ); + } + + if (members[aci]) { + log.warn( + `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.` + ); + return; + } + + members[aci] = { + uuid: aci, + joinedAtVersion: version, + role: previousRecord.role || MEMBER_ROLE_ENUM.DEFAULT, + }; + + newProfileKeys.push({ + profileKey, + uuid: aci, + }); + } + ); + // modifyTitle?: GroupChange.Actions.ModifyTitleAction; if (actions.modifyTitle) { const { title } = actions.modifyTitle; @@ -5002,8 +5136,8 @@ async function applyGroupChange({ }); } - if (ourUuid) { - result.left = !members[ourUuid]; + if (ourACI) { + result.left = !members[ourACI]; } if (result.left) { result.addedBy = undefined; @@ -5166,15 +5300,15 @@ async function applyGroupState({ // Optimization: we assume we have left the group unless we are found in members result.left = true; - const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI).toString(); // members const wasPreviouslyAMember = (result.membersV2 || []).some( - item => item.uuid !== ourUuid + item => item.uuid !== ourACI ); if (groupState.members) { result.membersV2 = groupState.members.map(member => { - if (member.userId === ourUuid) { + if (member.userId === ourACI) { result.left = false; // Capture who added us if we were previously not in group @@ -5371,6 +5505,11 @@ type DecryptedGroupChangeActions = { profileKey: Uint8Array; uuid: UUIDStringType; }>; + promoteMembersPendingPniAciProfileKey?: ReadonlyArray<{ + profileKey: Uint8Array; + aci: UUIDStringType; + pni: UUIDStringType; + }>; modifyTitle?: { title?: Proto.GroupAttributeBlob; }; @@ -5549,38 +5688,61 @@ function decryptGroupChange( // >; result.modifyMemberProfileKeys = compact( (actions.modifyMemberProfileKeys || []).map(modifyMemberProfileKey => { - const { presentation } = modifyMemberProfileKey; - strictAssert( - Bytes.isNotEmpty(presentation), - 'decryptGroupChange: modifyMemberProfileKey.presentation was missing' - ); + let { userId, profileKey: encryptedProfileKey } = modifyMemberProfileKey; - const decryptedPresentation = decryptProfileKeyCredentialPresentation( - clientZkGroupCipher, - presentation - ); + // TODO: DESKTOP-3816 + if (Bytes.isEmpty(userId) || Bytes.isEmpty(encryptedProfileKey)) { + const { presentation } = modifyMemberProfileKey; - if (!decryptedPresentation.uuid || !decryptedPresentation.profileKey) { - throw new Error( - 'decryptGroupChange: uuid or profileKey missing after modifyMemberProfileKey decryption!' + strictAssert( + Bytes.isNotEmpty(presentation), + 'decryptGroupChange: modifyMemberProfileKeys.presentation was missing' ); + + const decodedPresentation = + decodeProfileKeyCredentialPresentation(presentation); + + ({ userId, profileKey: encryptedProfileKey } = decodedPresentation); } - if (!isValidUuid(decryptedPresentation.uuid)) { - log.warn( - `decryptGroupChange/${logId}: Dropping modifyMemberProfileKey due to invalid userId` + strictAssert( + Bytes.isNotEmpty(userId), + 'decryptGroupChange: modifyMemberProfileKeys.userId was missing' + ); + strictAssert( + Bytes.isNotEmpty(encryptedProfileKey), + 'decryptGroupChange: modifyMemberProfileKeys.profileKey was missing' + ); + + let uuid: UUIDStringType; + let profileKey: Uint8Array; + try { + uuid = normalizeUuid( + decryptUuid(clientZkGroupCipher, userId), + 'actions.modifyMemberProfileKeys.userId' ); + profileKey = decryptProfileKey( + clientZkGroupCipher, + encryptedProfileKey, + uuid + ); + } catch (error) { + log.warn( + `decryptGroupChange/${logId}: Unable to decrypt ` + + 'modifyMemberProfileKeys.userId/profileKey. Dropping member.', + Errors.toLogFormat(error) + ); return null; } - if (!isValidProfileKey(decryptedPresentation.profileKey)) { + if (!isValidProfileKey(profileKey)) { throw new Error( 'decryptGroupChange: modifyMemberProfileKey had invalid profileKey' ); } - return decryptedPresentation; + return { uuid, profileKey }; }) ); @@ -5651,40 +5813,146 @@ function decryptGroupChange( // >; result.promotePendingMembers = compact( (actions.promotePendingMembers || []).map(promotePendingMember => { - const { presentation } = promotePendingMember; - strictAssert( - Bytes.isNotEmpty(presentation), - 'decryptGroupChange: promotePendingMember.presentation was missing' - ); - const decryptedPresentation = decryptProfileKeyCredentialPresentation( - clientZkGroupCipher, - presentation - ); + let { userId, profileKey: encryptedProfileKey } = promotePendingMember; - if (!decryptedPresentation.uuid || !decryptedPresentation.profileKey) { - throw new Error( - 'decryptGroupChange: uuid or profileKey missing after promotePendingMember decryption!' + // TODO: DESKTOP-3816 + if (Bytes.isEmpty(userId) || Bytes.isEmpty(encryptedProfileKey)) { + const { presentation } = promotePendingMember; + + strictAssert( + Bytes.isNotEmpty(presentation), + 'decryptGroupChange: promotePendingMember.presentation was missing' ); + + const decodedPresentation = + decodeProfileKeyCredentialPresentation(presentation); + + ({ userId, profileKey: encryptedProfileKey } = decodedPresentation); } - if (!isValidUuid(decryptedPresentation.uuid)) { - log.warn( - `decryptGroupChange/${logId}: Dropping modifyMemberProfileKey due to invalid userId` + strictAssert( + Bytes.isNotEmpty(userId), + 'decryptGroupChange: promotePendingMembers.userId was missing' + ); + strictAssert( + Bytes.isNotEmpty(encryptedProfileKey), + 'decryptGroupChange: promotePendingMembers.profileKey was missing' + ); + + let uuid: UUIDStringType; + let profileKey: Uint8Array; + try { + uuid = normalizeUuid( + decryptUuid(clientZkGroupCipher, userId), + 'actions.promotePendingMembers.userId' ); + profileKey = decryptProfileKey( + clientZkGroupCipher, + encryptedProfileKey, + uuid + ); + } catch (error) { + log.warn( + `decryptGroupChange/${logId}: Unable to decrypt ` + + 'promotePendingMembers.userId/profileKey. Dropping member.', + Errors.toLogFormat(error) + ); return null; } - if (!isValidProfileKey(decryptedPresentation.profileKey)) { + if (!isValidProfileKey(profileKey)) { throw new Error( - 'decryptGroupChange: modifyMemberProfileKey had invalid profileKey' + 'decryptGroupChange: promotePendingMembers had invalid profileKey' ); } - return decryptedPresentation; + return { uuid, profileKey }; }) ); + // promoteMembersPendingPniAciProfileKey?: Array< + // GroupChange.Actions.PromoteMemberPendingPniAciProfileKeyAction + // >; + result.promoteMembersPendingPniAciProfileKey = compact( + (actions.promoteMembersPendingPniAciProfileKey || []).map( + promotePendingMember => { + strictAssert( + Bytes.isNotEmpty(promotePendingMember.userId), + 'decryptGroupChange: ' + + 'promoteMembersPendingPniAciProfileKey.userId was missing' + ); + strictAssert( + Bytes.isNotEmpty(promotePendingMember.pni), + 'decryptGroupChange: ' + + 'promoteMembersPendingPniAciProfileKey.pni was missing' + ); + strictAssert( + Bytes.isNotEmpty(promotePendingMember.profileKey), + 'decryptGroupChange: ' + + 'promoteMembersPendingPniAciProfileKey.profileKey was missing' + ); + + let userId: string; + let pni: string; + let profileKey: Uint8Array; + try { + userId = normalizeUuid( + decryptUuid(clientZkGroupCipher, promotePendingMember.userId), + 'actions.promoteMembersPendingPniAciProfileKey.userId' + ); + pni = normalizeUuid( + decryptUuid(clientZkGroupCipher, promotePendingMember.pni), + 'actions.promoteMembersPendingPniAciProfileKey.pni' + ); + + profileKey = decryptProfileKey( + clientZkGroupCipher, + promotePendingMember.profileKey, + UUID.cast(userId) + ); + } catch (error) { + log.warn( + `decryptGroupChange/${logId}: Unable to decrypt promoteMembersPendingPniAciProfileKey. Dropping member.`, + Errors.toLogFormat(error) + ); + return null; + } + + if (!isValidUuid(userId)) { + log.warn( + `decryptGroupChange/${logId}: Dropping ` + + 'promoteMembersPendingPniAciProfileKey due to invalid ACI' + ); + + return null; + } + + if (!isValidUuid(pni)) { + log.warn( + `decryptGroupChange/${logId}: Dropping ` + + 'promoteMembersPendingPniAciProfileKey due to invalid PNI' + ); + + return null; + } + + if (!isValidProfileKey(profileKey)) { + throw new Error( + 'decryptGroupChange: promoteMembersPendingPniAciProfileKey ' + + 'had invalid profileKey' + ); + } + + return { + aci: userId, + pni, + profileKey, + }; + } + ) + ); + // modifyTitle?: GroupChange.Actions.ModifyTitleAction; if (actions.modifyTitle) { const { title } = actions.modifyTitle; @@ -6473,12 +6741,9 @@ export function getMembershipList( const clientZkGroupCipher = getClientZkGroupCipher(secretParams); return conversation.getMembers().map(member => { - const uuid = member.get('uuid'); - if (!uuid) { - throw new Error('getMembershipList: member has no UUID'); - } + const uuid = member.getCheckedUuid('getMembershipList: member has no UUID'); const uuidCiphertext = encryptUuid(clientZkGroupCipher, uuid); - return { uuid, uuidCiphertext }; + return { uuid: uuid.toString(), uuidCiphertext }; }); } diff --git a/ts/groups/joinViaLink.tsx b/ts/groups/joinViaLink.tsx index 1379e486a..38a559894 100644 --- a/ts/groups/joinViaLink.tsx +++ b/ts/groups/joinViaLink.tsx @@ -13,6 +13,7 @@ import { parseGroupLink, } from '../groups'; import * as Errors from '../types/errors'; +import { UUIDKind } from '../types/UUID'; import * as Bytes from '../Bytes'; import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper'; import { isGroupV1 } from '../util/whatTypeOfConversation'; @@ -64,13 +65,9 @@ export async function joinViaLink(hash: string): Promise { const existingConversation = window.ConversationController.get(id) || window.ConversationController.getByDerivedGroupV2Id(id); - const ourConversationId = - window.ConversationController.getOurConversationIdOrThrow(); + const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); - if ( - existingConversation && - existingConversation.hasMember(ourConversationId) - ) { + if (existingConversation && existingConversation.hasMember(ourUuid)) { log.warn( `joinViaLink/${logId}: Already a member of group, opening conversation` ); @@ -152,7 +149,7 @@ export async function joinViaLink(hash: string): Promise { if ( approvalRequired && existingConversation && - existingConversation.isMemberAwaitingApproval(ourConversationId) + existingConversation.isMemberAwaitingApproval(ourUuid) ) { log.warn( `joinViaLink/${logId}: Already awaiting approval, opening conversation` @@ -246,9 +243,9 @@ export async function joinViaLink(hash: string): Promise { // via some other process. If so, just open that conversation. if ( targetConversation && - (targetConversation.hasMember(ourConversationId) || + (targetConversation.hasMember(ourUuid) || (approvalRequired && - targetConversation.isMemberAwaitingApproval(ourConversationId))) + targetConversation.isMemberAwaitingApproval(ourUuid))) ) { log.warn( `joinViaLink/${logId}: User is part of group on second check, opening conversation` diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 69e3a7d93..bf690385b 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -21,7 +21,7 @@ import { AttachmentDraftType, AttachmentType } from './types/Attachment'; import { EmbeddedContactType } from './types/EmbeddedContact'; import { SignalService as Proto } from './protobuf'; import { AvatarDataType } from './types/Avatar'; -import { UUIDStringType } from './types/UUID'; +import { UUIDStringType, UUIDKind } from './types/UUID'; import { ReactionSource } from './reactions/ReactionSource'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; @@ -282,6 +282,8 @@ export type ConversationAttributesType = { path: string; }; profileKeyCredential?: string | null; + profileKeyCredentialExpiration?: number | null; + pniCredential?: string | null; lastProfile?: ConversationLastProfileType; quotedMessageId?: string | null; sealedSender?: unknown; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b1dfe82fd..b1442bef5 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -24,6 +24,7 @@ import { parseNumber, } from '../util/libphonenumberUtil'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; +import { toDayMillis } from '../util/timestamp'; import type { AttachmentType } from '../types/Attachment'; import { isGIF } from '../types/Attachment'; import type { CallHistoryDetailsType } from '../types/Calling'; @@ -380,7 +381,7 @@ export class ConversationModel extends window.Backbone return { getGroupId: () => this.get('groupId'), getMembers: () => this.getMembers(), - hasMember: (id: string) => this.hasMember(id), + hasMember: (uuid: UUIDStringType) => this.hasMember(new UUID(uuid)), idForLogging: () => this.idForLogging(), isGroupV2: () => isGroupV2(this.attributes), isValid: () => isGroupV2(this.attributes), @@ -393,7 +394,7 @@ export class ConversationModel extends window.Backbone }; } - isMemberRequestingToJoin(id: string): boolean { + private isMemberRequestingToJoin(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -403,11 +404,10 @@ export class ConversationModel extends window.Backbone return false; } - const uuid = UUID.checkedLookup(id).toString(); - return pendingAdminApprovalV2.some(item => item.uuid === uuid); + return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString()); } - isMemberPending(id: string): boolean { + isMemberPending(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -417,11 +417,10 @@ export class ConversationModel extends window.Backbone return false; } - const uuid = UUID.checkedLookup(id).toString(); - return pendingMembersV2.some(item => item.uuid === uuid); + return pendingMembersV2.some(item => item.uuid === uuid.toString()); } - isMemberBanned(id: string): boolean { + private isMemberBanned(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -431,11 +430,10 @@ export class ConversationModel extends window.Backbone return false; } - const uuid = UUID.checkedLookup(id).toString(); - return bannedMembersV2.some(member => member.uuid === uuid); + return bannedMembersV2.some(member => member.uuid === uuid.toString()); } - isMemberAwaitingApproval(id: string): boolean { + isMemberAwaitingApproval(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -445,24 +443,22 @@ export class ConversationModel extends window.Backbone return false; } - const uuid = UUID.checkedLookup(id).toString(); - return window._.any(pendingAdminApprovalV2, item => item.uuid === uuid); + return pendingAdminApprovalV2.some( + member => member.uuid === uuid.toString() + ); } - isMember(id: string): boolean { + isMember(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { - throw new Error( - `isMember: Called for non-GroupV2 conversation ${this.idForLogging()}` - ); + return false; } const membersV2 = this.get('membersV2'); if (!membersV2 || !membersV2.length) { return false; } - const uuid = UUID.checkedLookup(id).toString(); - return window._.any(membersV2, item => item.uuid === uuid); + return window._.any(membersV2, item => item.uuid === uuid.toString()); } async updateExpirationTimerInGroupV2( @@ -485,117 +481,101 @@ export class ConversationModel extends window.Backbone }); } - async promotePendingMember( - conversationId: string + private async promotePendingMember( + uuidKind: UUIDKind ): Promise { const idLog = this.idForLogging(); + const us = window.ConversationController.getOurConversationOrThrow(); + const uuid = window.storage.user.getCheckedUuid(uuidKind); + // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (!this.isMemberPending(conversationId)) { + if (!this.isMemberPending(uuid)) { log.warn( - `promotePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + `promotePendingMember/${idLog}: we are not a pending member of group. Returning early.` ); return undefined; } - const pendingMember = window.ConversationController.get(conversationId); - if (!pendingMember) { - throw new Error( - `promotePendingMember/${idLog}: No conversation found for conversation ${conversationId}` - ); - } - // We need the user's profileKeyCredential, which requires a roundtrip with the // server, and most definitely their profileKey. A getProfiles() call will // ensure that we have as much as we can get with the data we have. - let profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential'); - if (!profileKeyCredentialBase64) { - await pendingMember.getProfiles(); - - profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential'); - if (!profileKeyCredentialBase64) { - throw new Error( - `promotePendingMember/${idLog}: No profileKeyCredential for conversation ${pendingMember.idForLogging()}` - ); + if (uuidKind === UUIDKind.ACI) { + if (!us.get('profileKeyCredential')) { + await us.getProfiles(); } + + const profileKeyCredentialBase64 = us.get('profileKeyCredential'); + strictAssert( + profileKeyCredentialBase64, + 'Must have profileKeyCredential' + ); + + return window.Signal.Groups.buildPromoteMemberChange({ + group: this.attributes, + profileKeyCredentialBase64, + serverPublicParamsBase64: window.getServerPublicParams(), + }); } + strictAssert(uuidKind === UUIDKind.PNI, 'Must be a PNI promotion'); + + // Similarly we need `pniCredential` even if this would require a server + // roundtrip. + if (!us.get('pniCredential')) { + await us.getProfiles(); + } + const pniCredentialBase64 = us.get('pniCredential'); + strictAssert(pniCredentialBase64, 'Must have pniCredential'); + return window.Signal.Groups.buildPromoteMemberChange({ group: this.attributes, - profileKeyCredentialBase64, + pniCredentialBase64, serverPublicParamsBase64: window.getServerPublicParams(), }); } - async approvePendingApprovalRequest( - conversationId: string + private async approvePendingApprovalRequest( + uuid: UUID ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (!this.isMemberRequestingToJoin(conversationId)) { + if (!this.isMemberRequestingToJoin(uuid)) { log.warn( - `approvePendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.` + `approvePendingApprovalRequest/${idLog}: ${uuid} is not requesting ` + + 'to join the group. Returning early.' ); return undefined; } - const pendingMember = window.ConversationController.get(conversationId); - if (!pendingMember) { - throw new Error( - `approvePendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` - ); - } - - const uuid = pendingMember.get('uuid'); - if (!uuid) { - throw new Error( - `approvePendingApprovalRequest/${idLog}: Missing uuid for conversation ${conversationId}` - ); - } - return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({ group: this.attributes, uuid, }); } - async denyPendingApprovalRequest( - conversationId: string + private async denyPendingApprovalRequest( + uuid: UUID ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (!this.isMemberRequestingToJoin(conversationId)) { + if (!this.isMemberRequestingToJoin(uuid)) { log.warn( - `denyPendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.` + `denyPendingApprovalRequest/${idLog}: ${uuid} is not requesting ` + + 'to join the group. Returning early.' ); return undefined; } - const pendingMember = window.ConversationController.get(conversationId); - if (!pendingMember) { - throw new Error( - `denyPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` - ); - } - - const uuid = pendingMember.get('uuid'); - if (!uuid) { - throw new Error( - `denyPendingApprovalRequest/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` - ); - } - - const ourUuid = window.textsecure.storage.user - .getCheckedUuid(UUIDKind.ACI) - .toString(); + const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({ group: this.attributes, @@ -620,6 +600,8 @@ export class ConversationModel extends window.Backbone ); } + const uuid = toRequest.getCheckedUuid(`addPendingApprovalRequest/${idLog}`); + // We need the user's profileKeyCredential, which requires a roundtrip with the // server, and most definitely their profileKey. A getProfiles() call will // ensure that we have as much as we can get with the data we have. @@ -638,9 +620,10 @@ export class ConversationModel extends window.Backbone // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (this.isMemberAwaitingApproval(conversationId)) { + if (this.isMemberAwaitingApproval(uuid)) { log.warn( - `addPendingApprovalRequest/${idLog}: ${conversationId} already in pending approval.` + `addPendingApprovalRequest/${idLog}: ` + + `${toRequest.idForLogging()} already in pending approval.` ); return undefined; } @@ -652,23 +635,12 @@ export class ConversationModel extends window.Backbone }); } - async addMember( - conversationId: string - ): Promise { + async addMember(uuid: UUID): Promise { const idLog = this.idForLogging(); - const toRequest = window.ConversationController.get(conversationId); + const toRequest = window.ConversationController.get(uuid.toString()); if (!toRequest) { - throw new Error( - `addMember/${idLog}: No conversation found for conversation ${conversationId}` - ); - } - - const uuid = toRequest.get('uuid'); - if (!uuid) { - throw new Error( - `addMember/${idLog}: ${toRequest.idForLogging()} is missing a uuid!` - ); + throw new Error(`addMember/${idLog}: No conversation found for ${uuid}`); } // We need the user's profileKeyCredential, which requires a roundtrip with the @@ -689,8 +661,11 @@ export class ConversationModel extends window.Backbone // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (this.isMember(conversationId)) { - log.warn(`addMember/${idLog}: ${conversationId} already a member.`); + if (this.isMember(uuid)) { + log.warn( + `addMember/${idLog}: ${toRequest.idForLogging()} ` + + 'is already a member.' + ); return undefined; } @@ -702,38 +677,23 @@ export class ConversationModel extends window.Backbone }); } - async removePendingMember( - conversationIds: Array + private async removePendingMember( + uuids: ReadonlyArray ): Promise { const idLog = this.idForLogging(); - const uuids = conversationIds - .map(conversationId => { + const pendingUuids = uuids + .map(uuid => { // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (!this.isMemberPending(conversationId)) { + if (!this.isMemberPending(uuid)) { log.warn( - `removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + `removePendingMember/${idLog}: ${uuid} is not a pending member of group. Returning early.` ); return undefined; } - const pendingMember = window.ConversationController.get(conversationId); - if (!pendingMember) { - log.warn( - `removePendingMember/${idLog}: No conversation found for conversation ${conversationId}` - ); - return undefined; - } - - const uuid = pendingMember.get('uuid'); - if (!uuid) { - log.warn( - `removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` - ); - return undefined; - } return uuid; }) .filter(isNotNil); @@ -744,42 +704,26 @@ export class ConversationModel extends window.Backbone return window.Signal.Groups.buildDeletePendingMemberChange({ group: this.attributes, - uuids, + uuids: pendingUuids, }); } - async removeMember( - conversationId: string + private async removeMember( + uuid: UUID ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (!this.isMember(conversationId)) { + if (!this.isMember(uuid)) { log.warn( - `removeMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + `removeMember/${idLog}: ${uuid} is not a pending member of group. Returning early.` ); return undefined; } - const member = window.ConversationController.get(conversationId); - if (!member) { - throw new Error( - `removeMember/${idLog}: No conversation found for conversation ${conversationId}` - ); - } - - const uuid = member.get('uuid'); - if (!uuid) { - throw new Error( - `removeMember/${idLog}: Missing uuid for conversation ${member.idForLogging()}` - ); - } - - const ourUuid = window.textsecure.storage.user - .getCheckedUuid(UUIDKind.ACI) - .toString(); + const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); return window.Signal.Groups.buildDeleteMemberChange({ group: this.attributes, @@ -788,8 +732,8 @@ export class ConversationModel extends window.Backbone }); } - async toggleAdminChange( - conversationId: string + private async toggleAdminChange( + uuid: UUID ): Promise { if (!isGroupV2(this.attributes)) { return undefined; @@ -797,30 +741,16 @@ export class ConversationModel extends window.Backbone const idLog = this.idForLogging(); - if (!this.isMember(conversationId)) { + if (!this.isMember(uuid)) { log.warn( - `toggleAdminChange/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + `toggleAdminChange/${idLog}: ${uuid} is not a pending member of group. Returning early.` ); return undefined; } - const conversation = window.ConversationController.get(conversationId); - if (!conversation) { - throw new Error( - `toggleAdminChange/${idLog}: No conversation found for conversation ${conversationId}` - ); - } - - const uuid = conversation.get('uuid'); - if (!uuid) { - throw new Error( - `toggleAdminChange/${idLog}: Missing uuid for conversation ${conversationId}` - ); - } - const MEMBER_ROLES = Proto.Member.Role; - const role = this.isAdmin(conversationId) + const role = this.isAdmin(uuid) ? MEMBER_ROLES.DEFAULT : MEMBER_ROLES.ADMINISTRATOR; @@ -832,11 +762,13 @@ export class ConversationModel extends window.Backbone } async modifyGroupV2({ + usingCredentialsFrom, createGroupChange, extraConversationsForSend, inviteLinkPassword, name, }: { + usingCredentialsFrom: ReadonlyArray; createGroupChange: () => Promise; extraConversationsForSend?: Array; inviteLinkPassword?: string; @@ -844,6 +776,7 @@ export class ConversationModel extends window.Backbone }): Promise { await window.Signal.Groups.modifyGroupV2({ conversation: this, + usingCredentialsFrom, createGroupChange, extraConversationsForSend, inviteLinkPassword, @@ -1828,6 +1761,9 @@ export class ConversationModel extends window.Backbone const { customColor, customColorId } = this.getCustomColorData(); + const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); + // TODO: DESKTOP-720 return { id: this.id, @@ -1844,11 +1780,13 @@ export class ConversationModel extends window.Backbone acceptedMessageRequest: this.getAccepted(), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion activeAt: this.get('active_at')!, - areWePending: Boolean( - ourConversationId && this.isMemberPending(ourConversationId) - ), + areWePending: + this.isMemberPending(ourACI) || + Boolean( + ourPNI && !this.isMember(ourACI) && this.isMemberPending(ourPNI) + ), areWePendingApproval: Boolean( - ourConversationId && this.isMemberAwaitingApproval(ourConversationId) + ourConversationId && this.isMemberAwaitingApproval(ourACI) ), areWeAdmin: this.areWeAdmin(), avatars: getAvatarData(this.attributes), @@ -2093,8 +2031,6 @@ export class ConversationModel extends window.Backbone try { const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; const isLocalAction = !fromSync && !viaStorageServiceSync; - const ourConversationId = - window.ConversationController.getOurConversationId(); const currentMessageRequestState = this.get('messageRequestResponseType'); const didResponseChange = response !== currentMessageRequestState; @@ -2116,26 +2052,36 @@ export class ConversationModel extends window.Backbone } if (isLocalAction) { + const ourACI = window.textsecure.storage.user.getCheckedUuid( + UUIDKind.ACI + ); + const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); + if ( isGroupV1(this.attributes) || isDirectConversation(this.attributes) ) { this.sendProfileKeyUpdate(); } else if ( - ourConversationId && isGroupV2(this.attributes) && - this.isMemberPending(ourConversationId) + this.isMemberPending(ourACI) ) { await this.modifyGroupV2({ name: 'promotePendingMember', - createGroupChange: () => - this.promotePendingMember(ourConversationId), + usingCredentialsFrom: [], + createGroupChange: () => this.promotePendingMember(UUIDKind.ACI), }); } else if ( - ourConversationId && + ourPNI && isGroupV2(this.attributes) && - this.isMember(ourConversationId) + this.isMemberPending(ourPNI) ) { + await this.modifyGroupV2({ + name: 'promotePendingMember', + usingCredentialsFrom: [], + createGroupChange: () => this.promotePendingMember(UUIDKind.PNI), + }); + } else if (isGroupV2(this.attributes) && this.isMember(ourACI)) { log.info( 'applyMessageRequestResponse/accept: Already a member of v2 group' ); @@ -2223,21 +2169,21 @@ export class ConversationModel extends window.Backbone inviteLinkPassword: string; approvalRequired: boolean; }): Promise { - const ourConversationId = - window.ConversationController.getOurConversationIdOrThrow(); - const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); + const ourACI = window.textsecure.storage.user.getCheckedUuid(); try { if (approvalRequired) { await this.modifyGroupV2({ name: 'requestToJoin', + usingCredentialsFrom: [], inviteLinkPassword, createGroupChange: () => this.addPendingApprovalRequest(), }); } else { await this.modifyGroupV2({ name: 'joinGroup', + usingCredentialsFrom: [], inviteLinkPassword, - createGroupChange: () => this.addMember(ourConversationId), + createGroupChange: () => this.addMember(ourACI), }); } } catch (error) { @@ -2256,7 +2202,7 @@ export class ConversationModel extends window.Backbone this.set({ pendingAdminApprovalV2: [ { - uuid: ourUuid, + uuid: ourACI.toString(), timestamp: Date.now(), }, ], @@ -2277,8 +2223,7 @@ export class ConversationModel extends window.Backbone } async cancelJoinRequest(): Promise { - const ourConversationId = - window.ConversationController.getOurConversationIdOrThrow(); + const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); const inviteLinkPassword = this.get('groupInviteLinkPassword'); if (!inviteLinkPassword) { @@ -2289,15 +2234,18 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'cancelJoinRequest', + usingCredentialsFrom: [], inviteLinkPassword, - createGroupChange: () => - this.denyPendingApprovalRequest(ourConversationId), + createGroupChange: () => this.denyPendingApprovalRequest(ourACI), }); } async addMembersV2(conversationIds: ReadonlyArray): Promise { await this.modifyGroupV2({ name: 'addMembersV2', + usingCredentialsFrom: conversationIds + .map(id => window.ConversationController.get(id)) + .filter(isNotNil), createGroupChange: () => window.Signal.Groups.buildAddMembersChange( this.attributes, @@ -2315,6 +2263,7 @@ export class ConversationModel extends window.Backbone ): Promise { await this.modifyGroupV2({ name: 'updateGroupAttributesV2', + usingCredentialsFrom: [], createGroupChange: () => window.Signal.Groups.buildUpdateAttributesChange( { @@ -2329,36 +2278,43 @@ export class ConversationModel extends window.Backbone } async leaveGroupV2(): Promise { - const ourConversationId = - window.ConversationController.getOurConversationId(); + if (!isGroupV2(this.attributes)) { + return; + } - if ( - ourConversationId && - isGroupV2(this.attributes) && - this.isMemberPending(ourConversationId) - ) { + const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); + + if (this.isMemberPending(ourACI)) { await this.modifyGroupV2({ name: 'delete', - createGroupChange: () => this.removePendingMember([ourConversationId]), + usingCredentialsFrom: [], + createGroupChange: () => this.removePendingMember([ourACI]), }); - } else if ( - ourConversationId && - isGroupV2(this.attributes) && - this.isMember(ourConversationId) - ) { + } else if (this.isMember(ourACI)) { await this.modifyGroupV2({ name: 'delete', - createGroupChange: () => this.removeMember(ourConversationId), + usingCredentialsFrom: [], + createGroupChange: () => this.removeMember(ourACI), + }); + // Keep PNI in pending if ACI was a member. + } else if (ourPNI && this.isMemberPending(ourPNI)) { + await this.modifyGroupV2({ + name: 'delete', + usingCredentialsFrom: [], + createGroupChange: () => this.removePendingMember([ourPNI]), }); } else { + const logId = this.idForLogging(); log.error( - 'leaveGroupV2: We were neither a member nor a pending member of the group' + 'leaveGroupV2: We were neither a member nor a pending member of ' + + `the group ${logId}` ); } } async addBannedMember( - uuid: UUIDStringType + uuid: UUID ): Promise { if (this.isMember(uuid)) { log.warn('addBannedMember: Member is a part of the group!'); @@ -2387,7 +2343,8 @@ export class ConversationModel extends window.Backbone async blockGroupLinkRequests(uuid: UUIDStringType): Promise { await this.modifyGroupV2({ name: 'addBannedMember', - createGroupChange: async () => this.addBannedMember(uuid), + usingCredentialsFrom: [], + createGroupChange: async () => this.addBannedMember(new UUID(uuid)), }); } @@ -2396,7 +2353,17 @@ export class ConversationModel extends window.Backbone return; } - if (!this.isMember(conversationId)) { + const logId = this.idForLogging(); + + const member = window.ConversationController.get(conversationId); + if (!member) { + log.error(`toggleAdmin/${logId}: ${conversationId} does not exist`); + return; + } + + const uuid = member.getCheckedUuid(`toggleAdmin/${logId}`); + + if (!this.isMember(uuid)) { log.error( `toggleAdmin: Member ${conversationId} is not a member of the group` ); @@ -2405,21 +2372,32 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'toggleAdmin', - createGroupChange: () => this.toggleAdminChange(conversationId), + usingCredentialsFrom: [member], + createGroupChange: () => this.toggleAdminChange(uuid), }); } async approvePendingMembershipFromGroupV2( conversationId: string ): Promise { - if ( - isGroupV2(this.attributes) && - this.isMemberRequestingToJoin(conversationId) - ) { + const logId = this.idForLogging(); + + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + throw new Error( + `approvePendingMembershipFromGroupV2/${logId}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = pendingMember.getCheckedUuid( + `approvePendingMembershipFromGroupV2/${logId}` + ); + + if (isGroupV2(this.attributes) && this.isMemberRequestingToJoin(uuid)) { await this.modifyGroupV2({ name: 'approvePendingApprovalRequest', - createGroupChange: () => - this.approvePendingApprovalRequest(conversationId), + usingCredentialsFrom: [pendingMember], + createGroupChange: () => this.approvePendingApprovalRequest(uuid), }); } } @@ -2431,55 +2409,89 @@ export class ConversationModel extends window.Backbone return; } - const [conversationId] = conversationIds; - // Only pending memberships can be revoked for multiple members at once if (conversationIds.length > 1) { + const uuids = conversationIds.map(id => { + const uuid = window.ConversationController.get(id)?.getUuid(); + strictAssert(uuid, `UUID does not exist for ${id}`); + return uuid; + }); await this.modifyGroupV2({ name: 'removePendingMember', - createGroupChange: () => this.removePendingMember(conversationIds), + usingCredentialsFrom: conversationIds + .map(id => window.ConversationController.get(id)) + .filter(isNotNil), + createGroupChange: () => this.removePendingMember(uuids), extraConversationsForSend: conversationIds, }); - } else if (this.isMemberRequestingToJoin(conversationId)) { + return; + } + + const [conversationId] = conversationIds; + + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + const logId = this.idForLogging(); + throw new Error( + `revokePendingMembershipsFromGroupV2/${logId}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = pendingMember.getCheckedUuid( + 'revokePendingMembershipsFromGroupV2' + ); + + if (this.isMemberRequestingToJoin(uuid)) { await this.modifyGroupV2({ name: 'denyPendingApprovalRequest', - createGroupChange: () => - this.denyPendingApprovalRequest(conversationId), + usingCredentialsFrom: [pendingMember], + createGroupChange: () => this.denyPendingApprovalRequest(uuid), extraConversationsForSend: [conversationId], }); - } else if (this.isMemberPending(conversationId)) { + } else if (this.isMemberPending(uuid)) { await this.modifyGroupV2({ name: 'removePendingMember', - createGroupChange: () => this.removePendingMember([conversationId]), + usingCredentialsFrom: [pendingMember], + createGroupChange: () => this.removePendingMember([uuid]), extraConversationsForSend: [conversationId], }); } } async removeFromGroupV2(conversationId: string): Promise { - if ( - isGroupV2(this.attributes) && - this.isMemberRequestingToJoin(conversationId) - ) { + if (!isGroupV2(this.attributes)) { + return; + } + + const logId = this.idForLogging(); + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + throw new Error( + `removeFromGroupV2/${logId}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = pendingMember.getCheckedUuid(`removeFromGroupV2/${logId}`); + + if (this.isMemberRequestingToJoin(uuid)) { await this.modifyGroupV2({ name: 'denyPendingApprovalRequest', - createGroupChange: () => - this.denyPendingApprovalRequest(conversationId), + usingCredentialsFrom: [pendingMember], + createGroupChange: () => this.denyPendingApprovalRequest(uuid), extraConversationsForSend: [conversationId], }); - } else if ( - isGroupV2(this.attributes) && - this.isMemberPending(conversationId) - ) { + } else if (this.isMemberPending(uuid)) { await this.modifyGroupV2({ name: 'removePendingMember', - createGroupChange: () => this.removePendingMember([conversationId]), + usingCredentialsFrom: [pendingMember], + createGroupChange: () => this.removePendingMember([uuid]), extraConversationsForSend: [conversationId], }); - } else if (isGroupV2(this.attributes) && this.isMember(conversationId)) { + } else if (this.isMember(uuid)) { await this.modifyGroupV2({ name: 'removeFromGroup', - createGroupChange: () => this.removeMember(conversationId), + usingCredentialsFrom: [pendingMember], + createGroupChange: () => this.removeMember(uuid), extraConversationsForSend: [conversationId], }); } else { @@ -3478,14 +3490,13 @@ export class ConversationModel extends window.Backbone }); } - isAdmin(id: string): boolean { + isAdmin(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } - const uuid = UUID.checkedLookup(id).toString(); const members = this.get('membersV2') || []; - const member = members.find(x => x.uuid === uuid); + const member = members.find(x => x.uuid === uuid.toString()); if (!member) { return false; } @@ -4187,6 +4198,7 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'updateInviteLinkPassword', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildInviteLinkPasswordChange( this.attributes, @@ -4218,6 +4230,7 @@ export class ConversationModel extends window.Backbone if (shouldCreateNewGroupLink) { await this.modifyGroupV2({ name: 'updateNewGroupLink', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildNewGroupLinkChange( this.attributes, @@ -4228,6 +4241,7 @@ export class ConversationModel extends window.Backbone } else { await this.modifyGroupV2({ name: 'updateAccessControlAddFromInviteLink', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildAccessControlAddFromInviteLinkChange( this.attributes, @@ -4262,6 +4276,7 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'updateAccessControlAddFromInviteLink', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildAccessControlAddFromInviteLinkChange( this.attributes, @@ -4285,6 +4300,7 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'updateAccessControlAttributes', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildAccessControlAttributesChange( this.attributes, @@ -4310,6 +4326,7 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'updateAccessControlMembers', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildAccessControlMembersChange( this.attributes, @@ -4335,6 +4352,7 @@ export class ConversationModel extends window.Backbone await this.modifyGroupV2({ name: 'updateAnnouncementsOnly', + usingCredentialsFrom: [], createGroupChange: async () => window.Signal.Groups.buildAnnouncementsOnlyChange( this.attributes, @@ -4377,6 +4395,7 @@ export class ConversationModel extends window.Backbone } await this.modifyGroupV2({ name: 'updateExpirationTimer', + usingCredentialsFrom: [], createGroupChange: () => this.updateExpirationTimerInGroupV2(providedExpireTimer), }); @@ -4588,10 +4607,7 @@ export class ConversationModel extends window.Backbone const ourGroups = await window.ConversationController.getAllGroupsInvolvingUuid(ourUuid); const sharedGroups = ourGroups - .filter( - c => - c.hasMember(ourUuid.toString()) && c.hasMember(theirUuid.toString()) - ) + .filter(c => c.hasMember(ourUuid) && c.hasMember(theirUuid)) .sort( (left, right) => (right.get('timestamp') || 0) - (left.get('timestamp') || 0) @@ -4733,6 +4749,8 @@ export class ConversationModel extends window.Backbone ); this.set({ profileKeyCredential: null, + profileKeyCredentialExpiration: null, + pniCredential: null, accessKey: null, sealedSender: SEALED_SENDER.UNKNOWN, }); @@ -4759,6 +4777,27 @@ export class ConversationModel extends window.Backbone return false; } + hasProfileKeyCredentialExpired(): boolean { + const profileKeyCredential = this.get('profileKeyCredential'); + const profileKeyCredentialExpiration = this.get( + 'profileKeyCredentialExpiration' + ); + + if (!profileKeyCredential) { + return false; + } + + if (!isNumber(profileKeyCredentialExpiration)) { + const logId = this.idForLogging(); + log.warn(`hasProfileKeyCredentialExpired(${logId}): missing expiration`); + return true; + } + + const today = toDayMillis(Date.now()); + + return profileKeyCredentialExpiration <= today; + } + deriveAccessKeyIfNeeded(): void { const profileKey = this.get('profileKey'); if (!profileKey) { @@ -4860,11 +4899,10 @@ export class ConversationModel extends window.Backbone await window.Signal.Data.updateConversation(this.attributes); } - hasMember(identifier: string): boolean { - const id = window.ConversationController.getConversationId(identifier); - const memberIds = this.getMemberIds(); + hasMember(uuid: UUID): boolean { + const members = this.getMembers(); - return window._.contains(memberIds, id); + return members.some(member => member.get('uuid') === uuid.toString()); } fetchContacts(): void { @@ -5234,9 +5272,19 @@ export class ConversationModel extends window.Backbone return; } + const sender = window.ConversationController.get(senderId); + if (!sender) { + return; + } + + const senderUuid = sender.getUuid(); + if (!senderUuid) { + return; + } + // Drop typing indicators for announcement only groups where the sender // is not an admin - if (this.get('announcementsOnly') && !this.isAdmin(senderId)) { + if (this.get('announcementsOnly') && !this.isAdmin(senderUuid)) { return; } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 123a22fce..58613050b 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -459,7 +459,12 @@ export class MessageModel extends window.Backbone.Model { conversationSelector: findAndFormatContact, ourConversationId, ourNumber: window.textsecure.storage.user.getNumber(), - ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + ourACI: window.textsecure.storage.user + .getCheckedUuid(UUIDKind.ACI) + .toString(), + ourPNI: window.textsecure.storage.user + .getCheckedUuid(UUIDKind.PNI) + .toString(), regionCode: window.storage.get('regionCode', 'ZZ'), accountSelector: (identifier?: string) => { const state = window.reduxStore.getState(); @@ -540,7 +545,12 @@ export class MessageModel extends window.Backbone.Model { const changes = GroupChange.renderChange(change, { i18n: window.i18n, - ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + ourACI: window.textsecure.storage.user + .getCheckedUuid(UUIDKind.ACI) + .toString(), + ourPNI: window.textsecure.storage.user + .getCheckedUuid(UUIDKind.PNI) + .toString(), renderContact: (conversationId: string) => { const conversation = window.ConversationController.get(conversationId); @@ -2213,12 +2223,14 @@ export class MessageModel extends window.Backbone.Model { publicParams: initialMessage.groupV2.publicParams, }); - // Standard GroupV2 modification codepath const existingRevision = conversation.get('revision'); + const isFirstUpdate = !_.isNumber(existingRevision); + + // Standard GroupV2 modification codepath const isV2GroupUpdate = initialMessage.groupV2 && _.isNumber(initialMessage.groupV2.revision) && - (!_.isNumber(existingRevision) || + (isFirstUpdate || initialMessage.groupV2.revision > existingRevision); if (isV2GroupUpdate && initialMessage.groupV2) { @@ -2247,9 +2259,9 @@ export class MessageModel extends window.Backbone.Model { } } - const ourConversationId = - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - window.ConversationController.getOurConversationId()!; + const ourACI = window.textsecure.storage.user.getCheckedUuid( + UUIDKind.ACI + ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const senderId = window.ConversationController.ensureContactIds({ e164: source, @@ -2273,15 +2285,17 @@ export class MessageModel extends window.Backbone.Model { return; } + const areWeMember = + !conversation.get('left') && conversation.hasMember(ourACI); + // Drop an incoming GroupV2 message if we or the sender are not part of the group // after applying the message's associated group changes. if ( type === 'incoming' && !isDirectConversation(conversation.attributes) && hasGroupV2Prop && - (conversation.get('left') || - !conversation.hasMember(ourConversationId) || - !conversation.hasMember(senderId)) + (!areWeMember || + (sourceUuid && !conversation.hasMember(new UUID(sourceUuid)))) ) { log.warn( `Received message destined for group ${conversation.idForLogging()}, which we or the sender are not a part of. Dropping.` @@ -2301,7 +2315,7 @@ export class MessageModel extends window.Backbone.Model { !hasGroupV2Prop && !isV1GroupUpdate && conversation.get('members') && - (conversation.get('left') || !conversation.hasMember(ourConversationId)) + !areWeMember ) { log.warn( `Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` @@ -2323,7 +2337,7 @@ export class MessageModel extends window.Backbone.Model { // Drop incoming messages to announcement only groups where sender is not admin if ( conversation.get('announcementsOnly') && - !conversation.isAdmin(senderId) + !conversation.isAdmin(UUID.checkedLookup(senderId)) ) { confirm(); return; diff --git a/ts/routineProfileRefresh.ts b/ts/routineProfileRefresh.ts index 2982260d0..619beb631 100644 --- a/ts/routineProfileRefresh.ts +++ b/ts/routineProfileRefresh.ts @@ -6,20 +6,76 @@ import PQueue from 'p-queue'; import * as log from './logging/log'; import { assert } from './util/assert'; +import { sleep } from './util/sleep'; import { missingCaseError } from './util/missingCaseError'; import { isNormalNumber } from './util/isNormalNumber'; import { take } from './util/iterables'; -import { isOlderThan } from './util/timestamp'; import type { ConversationModel } from './models/conversations'; import type { StorageInterface } from './types/Storage.d'; +import * as Errors from './types/errors'; import { getProfile } from './util/getProfile'; -import { MINUTE } from './util/durations'; +import { MINUTE, HOUR, DAY, MONTH } from './util/durations'; const STORAGE_KEY = 'lastAttemptedToRefreshProfilesAt'; -const MAX_AGE_TO_BE_CONSIDERED_ACTIVE = 30 * 24 * 60 * 60 * 1000; -const MAX_AGE_TO_BE_CONSIDERED_RECENTLY_REFRESHED = 1 * 24 * 60 * 60 * 1000; +const MAX_AGE_TO_BE_CONSIDERED_ACTIVE = MONTH; +const MAX_AGE_TO_BE_CONSIDERED_RECENTLY_REFRESHED = DAY; const MAX_CONVERSATIONS_TO_REFRESH = 50; -const MIN_ELAPSED_DURATION_TO_REFRESH_AGAIN = 12 * 3600 * 1000; +const MIN_ELAPSED_DURATION_TO_REFRESH_AGAIN = 12 * HOUR; +const MIN_REFRESH_DELAY = MINUTE; + +export class RoutineProfileRefresher { + private interval: NodeJS.Timeout | undefined; + + constructor( + private readonly options: { + getAllConversations: () => ReadonlyArray; + getOurConversationId: () => string | undefined; + storage: Pick; + } + ) {} + + public async start(): Promise { + if (this.interval !== undefined) { + clearInterval(this.interval); + } + + const { storage, getAllConversations, getOurConversationId } = this.options; + + // eslint-disable-next-line no-constant-condition + while (true) { + const refreshInMs = timeUntilNextRefresh(storage); + + log.info(`routineProfileRefresh: waiting for ${refreshInMs}ms`); + + // eslint-disable-next-line no-await-in-loop + await sleep(refreshInMs); + + const ourConversationId = getOurConversationId(); + if (!ourConversationId) { + log.warn('routineProfileRefresh: missing our conversation id'); + + // eslint-disable-next-line no-await-in-loop + await sleep(MIN_REFRESH_DELAY); + + continue; + } + + try { + // eslint-disable-next-line no-await-in-loop + await routineProfileRefresh({ + allConversations: getAllConversations(), + ourConversationId, + storage, + }); + } catch (error) { + log.error('routineProfileRefresh: failure', Errors.toLogFormat(error)); + + // eslint-disable-next-line no-await-in-loop + await sleep(MIN_REFRESH_DELAY); + } + } + } +} export async function routineProfileRefresh({ allConversations, @@ -29,14 +85,15 @@ export async function routineProfileRefresh({ // Only for tests getProfileFn = getProfile, }: { - allConversations: Array; + allConversations: ReadonlyArray; ourConversationId: string; storage: Pick; getProfileFn?: typeof getProfile; }): Promise { log.info('routineProfileRefresh: starting'); - if (!hasEnoughTimeElapsedSinceLastRefresh(storage)) { + const refreshInMs = timeUntilNextRefresh(storage); + if (refreshInMs > 0) { log.info('routineProfileRefresh: too soon to refresh. Doing nothing'); return; } @@ -91,24 +148,24 @@ export async function routineProfileRefresh({ ); } -function hasEnoughTimeElapsedSinceLastRefresh( - storage: Pick -): boolean { +function timeUntilNextRefresh(storage: Pick): number { const storedValue = storage.get(STORAGE_KEY); if (isNil(storedValue)) { - return true; + return 0; } if (isNormalNumber(storedValue)) { - return isOlderThan(storedValue, MIN_ELAPSED_DURATION_TO_REFRESH_AGAIN); + const planned = storedValue + MIN_ELAPSED_DURATION_TO_REFRESH_AGAIN; + const now = Date.now(); + return Math.max(0, planned - now); } assert( false, `An invalid value was stored in ${STORAGE_KEY}; treating it as nil` ); - return true; + return 0; } function getConversationsToRefresh( @@ -134,6 +191,20 @@ function* getFilteredConversations( const type = conversation.get('type'); switch (type) { case 'private': + if ( + conversation.hasProfileKeyCredentialExpired() && + (conversation.id === ourConversationId || + !conversationIdsSeen.has(conversation.id)) + ) { + conversation.set({ + profileKeyCredential: null, + profileKeyCredentialExpiration: null, + }); + conversationIdsSeen.add(conversation.id); + yield conversation; + break; + } + if ( !conversationIdsSeen.has(conversation.id) && isConversationActive(conversation) && diff --git a/ts/services/groupCredentialFetcher.ts b/ts/services/groupCredentialFetcher.ts index 402bc48e6..63c9ffe7f 100644 --- a/ts/services/groupCredentialFetcher.ts +++ b/ts/services/groupCredentialFetcher.ts @@ -2,14 +2,16 @@ // SPDX-License-Identifier: AGPL-3.0-only import { last, sortBy } from 'lodash'; -import { AuthCredentialResponse } from '@signalapp/libsignal-client/zkgroup'; +import { AuthCredentialWithPniResponse } from '@signalapp/libsignal-client/zkgroup'; import { getClientZkAuthOperations } from '../util/zkgroup'; import type { GroupCredentialType } from '../textsecure/WebAPI'; +import { strictAssert } from '../util/assert'; import * as durations from '../util/durations'; import { BackOff } from '../util/BackOff'; import { sleep } from '../util/sleep'; +import { toDayMillis } from '../util/timestamp'; import { UUIDKind } from '../types/UUID'; import * as log from '../logging/log'; @@ -17,20 +19,25 @@ export const GROUP_CREDENTIALS_KEY = 'groupCredentials'; type CredentialsDataType = Array; type RequestDatesType = { - startDay: number; - endDay: number; + startDayInMs: number; + endDayInMs: number; }; type NextCredentialsType = { today: GroupCredentialType; tomorrow: GroupCredentialType; }; -function getTodayInEpoch() { - return Math.floor(Date.now() / durations.DAY); -} - let started = false; +function getCheckedCredentials(reason: string): CredentialsDataType { + const result = window.storage.get('groupCredentials'); + strictAssert( + result !== undefined, + `getCheckedCredentials: no credentials found, ${reason}` + ); + return result; +} + export async function initializeGroupCredentialFetcher(): Promise { if (started) { return; @@ -89,16 +96,14 @@ export async function runWithRetry( } // In cases where we are at a day boundary, we might need to use tomorrow in a retry -export function getCredentialsForToday( - data: CredentialsDataType | undefined +export function getCheckedCredentialsForToday( + reason: string ): NextCredentialsType { - if (!data) { - throw new Error('getCredentialsForToday: No credentials fetched!'); - } + const data = getCheckedCredentials(reason); - const todayInEpoch = getTodayInEpoch(); + const today = toDayMillis(Date.now()); const todayIndex = data.findIndex( - (item: GroupCredentialType) => item.redemptionTime === todayInEpoch + (item: GroupCredentialType) => item.redemptionTime === today ); if (todayIndex < 0) { throw new Error( @@ -113,29 +118,37 @@ export function getCredentialsForToday( } export async function maybeFetchNewCredentials(): Promise { - const uuid = window.textsecure.storage.user.getUuid()?.toString(); - if (!uuid) { - log.info('maybeFetchCredentials: no UUID, returning early'); + const logId = 'maybeFetchNewCredentials'; + + const aci = window.textsecure.storage.user.getUuid(UUIDKind.ACI)?.toString(); + if (!aci) { + log.info(`${logId}: no ACI, returning early`); return; } - const previous: CredentialsDataType | undefined = window.storage.get( - GROUP_CREDENTIALS_KEY - ); + + const pni = window.textsecure.storage.user.getUuid(UUIDKind.PNI)?.toString(); + if (!pni) { + log.info(`${logId}: no PNI, returning early`); + return; + } + + const previous: CredentialsDataType | undefined = + window.storage.get('groupCredentials'); const requestDates = getDatesForRequest(previous); if (!requestDates) { - log.info('maybeFetchCredentials: no new credentials needed'); + log.info(`${logId}: no new credentials needed`); return; } - const accountManager = window.getAccountManager(); - if (!accountManager) { - log.info('maybeFetchCredentials: unable to get AccountManager'); + const { server } = window.textsecure; + if (!server) { + log.error(`${logId}: unable to get server`); return; } - const { startDay, endDay } = requestDates; + const { startDayInMs, endDayInMs } = requestDates; log.info( - `maybeFetchCredentials: fetching credentials for ${startDay} through ${endDay}` + `${logId}: fetching credentials for ${startDayInMs} through ${endDayInMs}` ); const serverPublicParamsBase64 = window.getServerPublicParams(); @@ -143,46 +156,47 @@ export async function maybeFetchNewCredentials(): Promise { serverPublicParamsBase64 ); const newCredentials = sortCredentials( - await accountManager.getGroupCredentials(startDay, endDay, UUIDKind.ACI) + await server.getGroupCredentials({ startDayInMs, endDayInMs }) ).map((item: GroupCredentialType) => { - const authCredential = clientZKAuthOperations.receiveAuthCredential( - uuid, + const authCredential = clientZKAuthOperations.receiveAuthCredentialWithPni( + aci, + pni, item.redemptionTime, - new AuthCredentialResponse(Buffer.from(item.credential, 'base64')) + new AuthCredentialWithPniResponse(Buffer.from(item.credential, 'base64')) ); const credential = authCredential.serialize().toString('base64'); return { - redemptionTime: item.redemptionTime, + redemptionTime: item.redemptionTime * durations.SECOND, credential, }; }); - const todayInEpoch = getTodayInEpoch(); + const today = toDayMillis(Date.now()); const previousCleaned = previous ? previous.filter( - (item: GroupCredentialType) => item.redemptionTime >= todayInEpoch + (item: GroupCredentialType) => item.redemptionTime >= today ) : []; const finalCredentials = [...previousCleaned, ...newCredentials]; - log.info('maybeFetchCredentials: Saving new credentials...'); + log.info(`${logId}: Saving new credentials...`); // Note: we don't wait for this to finish - window.storage.put(GROUP_CREDENTIALS_KEY, finalCredentials); - log.info('maybeFetchCredentials: Save complete.'); + window.storage.put('groupCredentials', finalCredentials); + log.info(`${logId}: Save complete.`); } export function getDatesForRequest( data?: CredentialsDataType ): RequestDatesType | undefined { - const todayInEpoch = getTodayInEpoch(); - const oneWeekOut = todayInEpoch + 7; + const today = toDayMillis(Date.now()); + const oneWeekOut = today + durations.WEEK; const lastCredential = last(data); - if (!lastCredential || lastCredential.redemptionTime < todayInEpoch) { + if (!lastCredential || lastCredential.redemptionTime < today) { return { - startDay: todayInEpoch, - endDay: oneWeekOut, + startDayInMs: today, + endDayInMs: oneWeekOut, }; } @@ -191,8 +205,8 @@ export function getDatesForRequest( } return { - startDay: lastCredential.redemptionTime + 1, - endDay: oneWeekOut, + startDayInMs: lastCredential.redemptionTime + durations.DAY, + endDayInMs: oneWeekOut, }; } diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index f5aafc4a6..7341bfc0f 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -205,6 +205,7 @@ const dataInterface: ClientInterface = { updateConversations, removeConversation, updateAllConversationColors, + removeAllProfileKeyCredentials, getAllConversations, getAllConversationIds, @@ -1161,7 +1162,7 @@ async function getMessageBySender({ sent_at, }: { source: string; - sourceUuid: string; + sourceUuid: UUIDStringType; sourceDevice: number; sent_at: number; }) { @@ -1271,7 +1272,7 @@ async function getOlderStories(options: { limit?: number; receivedAt?: number; sentAt?: number; - sourceUuid?: string; + sourceUuid?: UUIDStringType; }): Promise> { return channels.getOlderStories(options); } @@ -1794,6 +1795,10 @@ async function updateAllConversationColors( ); } +async function removeAllProfileKeyCredentials(): Promise { + return channels.removeAllProfileKeyCredentials(); +} + function getMaxMessageCounter(): Promise { return channels.getMaxMessageCounter(); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 44c964b10..f6c7bcdb3 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -202,7 +202,7 @@ export type UnprocessedType = { messageAgeSec?: number; source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; sourceDevice?: number; destinationUuid?: string; serverGuid?: string; @@ -213,7 +213,7 @@ export type UnprocessedType = { export type UnprocessedUpdateType = { source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; sourceDevice?: number; serverGuid?: string; serverTimestamp?: number; @@ -257,8 +257,8 @@ export type StoryDistributionWithMembersType = Readonly< export type StoryReadType = Readonly<{ authorId: UUIDStringType; - conversationId: UUIDStringType; - storyId: UUIDStringType; + conversationId: string; + storyId: string; storyReadDate: number; }>; @@ -362,6 +362,7 @@ export type DataInterface = { value: CustomColorType; } ) => Promise; + removeAllProfileKeyCredentials: () => Promise; getAllConversations: () => Promise>; getAllConversationIds: () => Promise>; @@ -439,7 +440,7 @@ export type DataInterface = { _removeAllReactions: () => Promise; getMessageBySender: (options: { source: string; - sourceUuid: string; + sourceUuid: UUIDStringType; sourceDevice: number; sent_at: number; }) => Promise; @@ -462,7 +463,7 @@ export type DataInterface = { limit?: number; receivedAt?: number; sentAt?: number; - sourceUuid?: string; + sourceUuid?: UUIDStringType; }) => Promise>; // getNewerMessagesByConversation is JSON on server, full message on Client getMessageMetricsForConversation: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index d3a4b348e..10a8348f9 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -200,6 +200,7 @@ const dataInterface: ServerInterface = { updateConversations, removeConversation, updateAllConversationColors, + removeAllProfileKeyCredentials, getAllConversations, getAllConversationIds, @@ -2033,7 +2034,7 @@ async function getMessageBySender({ sent_at, }: { source: string; - sourceUuid: string; + sourceUuid: UUIDStringType; sourceDevice: number; sent_at: number; }): Promise { @@ -2443,7 +2444,7 @@ async function getOlderStories({ limit?: number; receivedAt?: number; sentAt?: number; - sourceUuid?: string; + sourceUuid?: UUIDStringType; }): Promise> { const db = getInstance(); const rows: JSONRows = db @@ -5067,3 +5068,15 @@ async function updateAllConversationColors( }), }); } + +async function removeAllProfileKeyCredentials(): Promise { + const db = getInstance(); + + db.exec( + ` + UPDATE conversations + SET + json = json_remove(json, '$.profileKeyCredential') + ` + ); +} diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index a63f6c0b0..b4672bd87 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -852,7 +852,7 @@ function groupCallStateChange( didSomeoneStartPresenting = false; } - const { ourUuid } = getState().user; + const { ourACI: ourUuid } = getState().user; strictAssert(ourUuid, 'groupCallStateChange failed to fetch our uuid'); dispatch({ diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index ba0d50b78..d7e0bacad 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -20,7 +20,6 @@ import { DAY } from '../../util/durations'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories'; import { ToastReactionFailed } from '../../components/ToastReactionFailed'; -import { UUID } from '../../types/UUID'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { getMessageById } from '../../messages/getMessageById'; import { markViewed } from '../../services/MessageUpdater'; @@ -271,7 +270,7 @@ function markStoryRead( await dataInterface.addNewStoryRead({ authorId: message.attributes.sourceUuid, conversationId: message.attributes.conversationId, - storyId: UUID.fromString(messageId), + storyId: messageId, storyReadDate, }); diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 4e422c2ed..0384b1c8d 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -18,7 +18,8 @@ export type UserStateType = { tempPath: string; ourConversationId: string | undefined; ourDeviceId: number | undefined; - ourUuid: UUIDStringType | undefined; + ourACI: UUIDStringType | undefined; + ourPNI: UUIDStringType | undefined; ourNumber: string | undefined; platform: string; regionCode: string | undefined; @@ -39,7 +40,8 @@ type UserChangedActionType = { payload: { ourConversationId?: string; ourDeviceId?: number; - ourUuid?: UUIDStringType; + ourACI?: UUIDStringType; + ourPNI?: UUIDStringType; ourNumber?: string; regionCode?: string; interactionMode?: 'mouse' | 'keyboard'; @@ -64,7 +66,8 @@ function userChanged(attributes: { ourConversationId?: string; ourDeviceId?: number; ourNumber?: string; - ourUuid?: UUIDStringType; + ourACI?: UUIDStringType; + ourPNI?: UUIDStringType; regionCode?: string; theme?: ThemeType; isMainWindowMaximized?: boolean; @@ -95,7 +98,8 @@ export function getEmptyState(): UserStateType { tempPath: 'missing', ourConversationId: 'missing', ourDeviceId: 0, - ourUuid: '00000000-0000-4000-8000-000000000000', + ourACI: undefined, + ourPNI: undefined, ourNumber: 'missing', regionCode: 'missing', platform: 'missing', diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index eb6da7690..a8a6fa7df 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -28,6 +28,7 @@ import type { StoryDataType } from './ducks/stories'; import type { StoryDistributionListDataType } from './ducks/storyDistributionLists'; import { getInitialState as stickers } from '../types/Stickers'; import type { MenuOptionsType } from '../types/menu'; +import { UUIDKind } from '../types/UUID'; import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis'; import type { MainWindowStatsType } from '../windows/context'; @@ -51,7 +52,12 @@ export function getInitialState({ conversation.format() ); const ourNumber = window.textsecure.storage.user.getNumber(); - const ourUuid = window.textsecure.storage.user.getUuid()?.toString(); + const ourACI = window.textsecure.storage.user + .getUuid(UUIDKind.ACI) + ?.toString(); + const ourPNI = window.textsecure.storage.user + .getUuid(UUIDKind.PNI) + ?.toString(); const ourConversationId = window.ConversationController.getOurConversationId(); const ourDeviceId = window.textsecure.storage.user.getDeviceId(); @@ -119,7 +125,8 @@ export function getInitialState({ ourConversationId, ourDeviceId, ourNumber, - ourUuid, + ourACI, + ourPNI, platform: window.platform, i18n: window.i18n, localeMessages: window.SignalContext.localeMessages, diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index dcad8920e..d37aecb6a 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -12,7 +12,7 @@ import type { GroupCallStateType, } from '../ducks/calling'; import { getIncomingCall as getIncomingCallHelper } from '../ducks/calling'; -import { getUserUuid } from './user'; +import { getUserACI } from './user'; import { getOwn } from '../../util/getOwn'; import { CallViewMode } from '../../types/Calling'; import type { UUIDStringType } from '../../types/UUID'; @@ -61,7 +61,7 @@ export const isInCall = createSelector( export const getIncomingCall = createSelector( getCallsByConversation, - getUserUuid, + getUserACI, ( callsByConversation: CallsByConversationType, ourUuid: UUIDStringType | undefined diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 8f59d7fbd..4604628f6 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -49,7 +49,8 @@ import { getRegionCode, getUserConversationId, getUserNumber, - getUserUuid, + getUserACI, + getUserPNI, } from './user'; import { getPinnedConversationIds } from './items'; import { getPropsForBubble } from './message'; @@ -780,7 +781,8 @@ export const getMessageSelector = createSelector( getConversationSelector, getRegionCode, getUserNumber, - getUserUuid, + getUserACI, + getUserPNI, getUserConversationId, getCallSelector, getActiveCall, @@ -793,7 +795,8 @@ export const getMessageSelector = createSelector( conversationSelector: GetConversationByIdType, regionCode: string | undefined, ourNumber: string | undefined, - ourUuid: UUIDStringType | undefined, + ourACI: UUIDStringType | undefined, + ourPNI: UUIDStringType | undefined, ourConversationId: string | undefined, callSelector: CallSelectorType, activeCall: undefined | CallStateType, @@ -810,7 +813,8 @@ export const getMessageSelector = createSelector( conversationSelector, ourConversationId, ourNumber, - ourUuid, + ourACI, + ourPNI, regionCode, selectedMessageId: selectedMessage?.id, selectedMessageCounter: selectedMessage?.counter, diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 0a312125d..ebe691b54 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -61,7 +61,8 @@ import { getRegionCode, getUserConversationId, getUserNumber, - getUserUuid, + getUserACI, + getUserPNI, } from './user'; import type { @@ -114,7 +115,8 @@ export type GetPropsForBubbleOptions = Readonly<{ conversationSelector: GetConversationByIdType; ourConversationId?: string; ourNumber?: string; - ourUuid?: UUIDStringType; + ourACI?: UUIDStringType; + ourPNI?: UUIDStringType; selectedMessageId?: string; selectedMessageCounter?: number; regionCode?: string; @@ -182,7 +184,7 @@ export function getSourceDevice( export function getSourceUuid( message: MessageWithUIFieldsType, - ourUuid: string | undefined + ourACI: string | undefined ): string | undefined { if (isIncoming(message)) { return message.sourceUuid; @@ -193,12 +195,16 @@ export function getSourceUuid( ); } - return ourUuid; + return ourACI; } export type GetContactOptions = Pick< GetPropsForBubbleOptions, - 'conversationSelector' | 'ourConversationId' | 'ourNumber' | 'ourUuid' + | 'conversationSelector' + | 'ourConversationId' + | 'ourNumber' + | 'ourACI' + | 'ourPNI' >; export function getContactId( @@ -207,11 +213,11 @@ export function getContactId( conversationSelector, ourConversationId, ourNumber, - ourUuid, + ourACI, }: GetContactOptions ): string | undefined { const source = getSource(message, ourNumber); - const sourceUuid = getSourceUuid(message, ourUuid); + const sourceUuid = getSourceUuid(message, ourACI); if (!source && !sourceUuid) { return ourConversationId; @@ -228,11 +234,11 @@ export function getContact( conversationSelector, ourConversationId, ourNumber, - ourUuid, + ourACI, }: GetContactOptions ): ConversationType { const source = getSource(message, ourNumber); - const sourceUuid = getSourceUuid(message, ourUuid); + const sourceUuid = getSourceUuid(message, ourACI); if (!source && !sourceUuid) { return conversationSelector(ourConversationId); @@ -563,7 +569,8 @@ export type GetPropsForMessageOptions = Pick< GetPropsForBubbleOptions, | 'conversationSelector' | 'ourConversationId' - | 'ourUuid' + | 'ourACI' + | 'ourPNI' | 'ourNumber' | 'selectedMessageId' | 'selectedMessageCounter' @@ -621,7 +628,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( conversationSelector, ourConversationId, ourNumber, - ourUuid, + ourACI, regionCode, selectedMessageId, selectedMessageCounter, @@ -652,7 +659,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( conversationSelector, ourConversationId, ourNumber, - ourUuid, + ourACI, }); const contactNameColor = contactNameColorSelector(conversationId, authorId); @@ -781,7 +788,8 @@ export const getPropsForMessage: ( export const getMessagePropsSelector = createSelector( getConversationSelector, getUserConversationId, - getUserUuid, + getUserACI, + getUserPNI, getUserNumber, getRegionCode, getAccountSelector, @@ -790,7 +798,8 @@ export const getMessagePropsSelector = createSelector( ( conversationSelector, ourConversationId, - ourUuid, + ourACI, + ourPNI, ourNumber, regionCode, accountSelector, @@ -804,7 +813,8 @@ export const getMessagePropsSelector = createSelector( conversationSelector, ourConversationId, ourNumber, - ourUuid, + ourACI, + ourPNI, regionCode, selectedMessageCounter: selectedMessage?.counter, selectedMessageId: selectedMessage?.id, @@ -977,7 +987,7 @@ export function isGroupV2Change(message: MessageWithUIFieldsType): boolean { function getPropsForGroupV2Change( message: MessageWithUIFieldsType, - { conversationSelector, ourUuid }: GetPropsForBubbleOptions + { conversationSelector, ourACI, ourPNI }: GetPropsForBubbleOptions ): GroupsV2Props { const change = message.groupV2Change; @@ -992,7 +1002,8 @@ function getPropsForGroupV2Change( groupName: conversation?.type === 'group' ? conversation?.name : undefined, groupMemberships: conversation.memberships, groupBannedMemberships: conversation.bannedMemberships, - ourUuid, + ourACI, + ourPNI, change, }; } diff --git a/ts/state/selectors/user.ts b/ts/state/selectors/user.ts index ed727a302..c67637d03 100644 --- a/ts/state/selectors/user.ts +++ b/ts/state/selectors/user.ts @@ -35,9 +35,14 @@ export const getUserConversationId = createSelector( (state: UserStateType): string | undefined => state.ourConversationId ); -export const getUserUuid = createSelector( +export const getUserACI = createSelector( getUser, - (state: UserStateType): UUIDStringType | undefined => state.ourUuid + (state: UserStateType): UUIDStringType | undefined => state.ourACI +); + +export const getUserPNI = createSelector( + getUser, + (state: UserStateType): UUIDStringType | undefined => state.ourPNI ); export const getIntl = createSelector( diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index dd4897314..c6a2fd9fe 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -17,7 +17,7 @@ import { CallMode } from '../../types/Calling'; import type { ConversationType } from '../ducks/conversations'; import { getConversationCallMode } from '../ducks/conversations'; import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling'; -import { getUserUuid, getIntl, getTheme } from '../selectors/user'; +import { getUserACI, getIntl, getTheme } from '../selectors/user'; import { getOwn } from '../../util/getOwn'; import { missingCaseError } from '../../util/missingCaseError'; import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; @@ -47,8 +47,8 @@ const getOutgoingCallButtonStyle = ( state: StateType ): OutgoingCallButtonStyle => { const { calling } = state; - const ourUuid = getUserUuid(state); - strictAssert(ourUuid, 'getOutgoingCallButtonStyle missing our uuid'); + const ourACI = getUserACI(state); + strictAssert(ourACI, 'getOutgoingCallButtonStyle missing our uuid'); if (getActiveCall(calling)) { return OutgoingCallButtonStyle.None; @@ -64,7 +64,7 @@ const getOutgoingCallButtonStyle = ( const call = getOwn(calling.callsByConversation, conversation.id); if ( call?.callMode === CallMode.Group && - isAnybodyElseInGroupCall(call.peekInfo, ourUuid) + isAnybodyElseInGroupCall(call.peekInfo, ourACI) ) { return OutgoingCallButtonStyle.Join; } diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index 2172ab3ac..97eab055c 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -14,7 +14,6 @@ import { getTheme, getUserConversationId, getUserNumber, - getUserUuid, } from '../selectors/user'; import { getMe } from '../selectors/conversations'; import { getStoriesEnabled } from '../selectors/items'; @@ -28,7 +27,6 @@ const mapStateToProps = (state: StateType) => { regionCode: getRegionCode(state), ourConversationId: getUserConversationId(state), ourNumber: getUserNumber(state), - ourUuid: getUserUuid(state), ...me, badge: getPreferredBadgeSelector(state)(me.badges), theme: getTheme(state), diff --git a/ts/test-both/groups/add_banned_member_test.ts b/ts/test-both/groups/add_banned_member_test.ts index 245d8a6b2..83d4cc2e7 100644 --- a/ts/test-both/groups/add_banned_member_test.ts +++ b/ts/test-both/groups/add_banned_member_test.ts @@ -11,8 +11,8 @@ import { updateRemoteConfig } from '../helpers/RemoteConfigStub'; const HARD_LIMIT_KEY = 'global.groupsv2.groupSizeHardLimit'; describe('group add banned member', () => { - const uuid = UUID.generate().toString(); - const ourUuid = UUID.generate().toString(); + const uuid = UUID.generate(); + const ourUuid = UUID.generate(); const existing = Array.from({ length: 10 }, (_, index) => ({ uuid: UUID.generate().toString(), timestamp: index, @@ -49,7 +49,7 @@ describe('group add banned member', () => { clientZkGroupCipher, actions.addMembersBanned?.[0]?.added?.userId ?? new Uint8Array(0) ), - uuid + uuid.toString() ); assert.strictEqual(actions.deleteMembersBanned, null); }); @@ -77,7 +77,7 @@ describe('group add banned member', () => { clientZkGroupCipher, actions.addMembersBanned?.[0]?.added?.userId ?? new Uint8Array(0) ), - uuid + uuid.toString() ); assert.deepStrictEqual( deleted, @@ -108,7 +108,7 @@ describe('group add banned member', () => { uuid, ourUuid, group: { - bannedMembersV2: [{ uuid, timestamp: 1 }], + bannedMembersV2: [{ uuid: uuid.toString(), timestamp: 1 }], }, }); diff --git a/ts/test-electron/routineProfileRefresh_test.ts b/ts/test-electron/routineProfileRefresh_test.ts index 3e76fd31a..ceaea31c8 100644 --- a/ts/test-electron/routineProfileRefresh_test.ts +++ b/ts/test-electron/routineProfileRefresh_test.ts @@ -6,6 +6,7 @@ import { times } from 'lodash'; import { ConversationModel } from '../models/conversations'; import type { ConversationAttributesType } from '../model-types.d'; import { UUID } from '../types/UUID'; +import { DAY } from '../util/durations'; import { routineProfileRefresh } from '../routineProfileRefresh'; @@ -44,6 +45,7 @@ describe('routineProfileRefresh', () => { muteExpiresAt: 0, profileAvatar: undefined, profileKeyCredential: UUID.generate().toString(), + profileKeyCredentialExpiration: Date.now() + 2 * DAY, profileSharing: true, quotedMessageId: null, sealedSender: 1, diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 036e85623..0bc28536d 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -157,7 +157,7 @@ describe('calling duck', () => { }, }; - const ourUuid = UUID.generate().toString(); + const ourACI = UUID.generate().toString(); const getEmptyRootState = () => { const rootState = rootReducer(undefined, noopAction()); @@ -165,7 +165,7 @@ describe('calling duck', () => { ...rootState, user: { ...rootState.user, - ourUuid, + ourACI, }, }; }; diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 556a61d7b..977c2cf5a 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -4,6 +4,7 @@ import { assert } from 'chai'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; +import { actions as userActions } from '../../../state/ducks/user'; import { CallMode, CallState, @@ -25,7 +26,15 @@ import type { import { getEmptyState } from '../../../state/ducks/calling'; describe('state/selectors/calling', () => { - const getEmptyRootState = () => rootReducer(undefined, noopAction()); + const getEmptyRootState = () => { + const initial = rootReducer(undefined, noopAction()); + return rootReducer( + initial, + userActions.userChanged({ + ourACI: '00000000-0000-4000-8000-000000000000', + }) + ); + }; const getCallingState = (calling: CallingStateType) => ({ ...getEmptyRootState(), diff --git a/ts/test-electron/util/sendToGroup_test.ts b/ts/test-electron/util/sendToGroup_test.ts index 0f4acd5b7..de7c56c11 100644 --- a/ts/test-electron/util/sendToGroup_test.ts +++ b/ts/test-electron/util/sendToGroup_test.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import * as sinon from 'sinon'; import { _analyzeSenderKeyDevices, _waitForAll, _shouldFailSend, } from '../../util/sendToGroup'; +import { UUID } from '../../types/UUID'; import type { DeviceType } from '../../textsecure/Types.d'; import { @@ -23,21 +25,39 @@ import { } from '../../textsecure/Errors'; describe('sendToGroup', () => { + const uuidOne = UUID.generate().toString(); + const uuidTwo = UUID.generate().toString(); + + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + const stub = sandbox.stub(UUID, 'lookup'); + stub.withArgs(uuidOne).returns(new UUID(uuidOne)); + stub.withArgs(uuidTwo).returns(new UUID(uuidTwo)); + stub.returns(undefined); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('#_analyzeSenderKeyDevices', () => { function getDefaultDeviceList(): Array { return [ { - identifier: 'ident-guid-one', + identifier: uuidOne, id: 1, registrationId: 11, }, { - identifier: 'ident-guid-one', + identifier: uuidOne, id: 2, registrationId: 22, }, { - identifier: 'ident-guid-two', + identifier: uuidTwo, id: 2, registrationId: 33, }, @@ -76,17 +96,17 @@ describe('sendToGroup', () => { assert.deepEqual(newToMemberDevices, [ { - identifier: 'ident-guid-one', + identifier: uuidOne, id: 2, registrationId: 22, }, { - identifier: 'ident-guid-two', + identifier: uuidTwo, id: 2, registrationId: 33, }, ]); - assert.deepEqual(newToMemberUuids, ['ident-guid-one', 'ident-guid-two']); + assert.deepEqual(newToMemberUuids, [uuidOne, uuidTwo]); assert.isEmpty(removedFromMemberDevices); assert.isEmpty(removedFromMemberUuids); }); @@ -108,20 +128,17 @@ describe('sendToGroup', () => { assert.isEmpty(newToMemberUuids); assert.deepEqual(removedFromMemberDevices, [ { - identifier: 'ident-guid-one', + identifier: uuidOne, id: 2, registrationId: 22, }, { - identifier: 'ident-guid-two', + identifier: uuidTwo, id: 2, registrationId: 33, }, ]); - assert.deepEqual(removedFromMemberUuids, [ - 'ident-guid-one', - 'ident-guid-two', - ]); + assert.deepEqual(removedFromMemberUuids, [uuidOne, uuidTwo]); }); it('returns empty removals if partial send', () => { const memberDevices = getDefaultDeviceList(); diff --git a/ts/test-mock/benchmarks/convo_open_bench.ts b/ts/test-mock/benchmarks/convo_open_bench.ts index cf8f98071..6dea7df68 100644 --- a/ts/test-mock/benchmarks/convo_open_bench.ts +++ b/ts/test-mock/benchmarks/convo_open_bench.ts @@ -2,16 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable no-await-in-loop, no-console */ +import assert from 'assert'; import type { PrimaryDevice } from '@signalapp/mock-server'; -import { - Bootstrap, - debug, - saveLogs, - stats, - RUN_COUNT, - DISCARD_COUNT, -} from './fixtures'; +import type { App } from './fixtures'; +import { Bootstrap, debug, stats, RUN_COUNT, DISCARD_COUNT } from './fixtures'; const CONVERSATION_SIZE = 1000; // messages const DELAY = 50; // milliseconds @@ -22,9 +17,10 @@ const DELAY = 50; // milliseconds }); await bootstrap.init(); - const app = await bootstrap.link(); + let app: App | undefined; try { + app = await bootstrap.link(); const { server, contacts, phone, desktop } = bootstrap; const [first, second] = contacts; @@ -65,6 +61,7 @@ const DELAY = 50; // milliseconds }; const measure = async (): Promise => { + assert(app); const window = await app.getWindow(); const leftPane = window.locator('.left-pane-wrapper'); @@ -102,10 +99,10 @@ const DELAY = 50; // milliseconds await Promise.all([sendQueue(), measure()]); } catch (error) { - await saveLogs(bootstrap); + await bootstrap.saveLogs(); throw error; } finally { - await app.close(); + await app?.close(); await bootstrap.teardown(); } })(); diff --git a/ts/test-mock/benchmarks/fixtures.ts b/ts/test-mock/benchmarks/fixtures.ts index 9ad51d10b..134c25723 100644 --- a/ts/test-mock/benchmarks/fixtures.ts +++ b/ts/test-mock/benchmarks/fixtures.ts @@ -3,8 +3,6 @@ /* eslint-disable no-console */ import createDebug from 'debug'; -import fs from 'fs/promises'; -import path from 'path'; import { Bootstrap } from '../bootstrap'; @@ -63,19 +61,6 @@ export function stats( return result; } -export async function saveLogs(bootstrap: Bootstrap): Promise { - const { ARTIFACTS_DIR } = process.env; - if (!ARTIFACTS_DIR) { - console.error('Not saving logs. Please set ARTIFACTS_DIR env variable'); - return; - } - - await fs.mkdir(ARTIFACTS_DIR, { recursive: true }); - - const { logsDir } = bootstrap; - await fs.rename(logsDir, path.join(ARTIFACTS_DIR, 'logs')); -} - // Can happen if electron exits prematurely process.on('unhandledRejection', reason => { console.error('Unhandled rejection:'); diff --git a/ts/test-mock/benchmarks/group_send_bench.ts b/ts/test-mock/benchmarks/group_send_bench.ts index 0f2a64068..b5dd3e9bb 100644 --- a/ts/test-mock/benchmarks/group_send_bench.ts +++ b/ts/test-mock/benchmarks/group_send_bench.ts @@ -9,10 +9,11 @@ import { EnvelopeType, ReceiptType, } from '@signalapp/mock-server'; + +import type { App } from './fixtures'; import { Bootstrap, debug, - saveLogs, stats, RUN_COUNT, GROUP_SIZE, @@ -44,9 +45,11 @@ const LAST_MESSAGE = 'start sending messages now'; .pinGroup(group) ); - const app = await bootstrap.link(); + let app: App | undefined; try { + app = await bootstrap.link(); + const { server, desktop } = bootstrap; const [first] = members; @@ -179,10 +182,10 @@ const LAST_MESSAGE = 'start sending messages now'; console.log('stats info=%j', { delta: stats(deltaList, [99, 99.8]) }); } catch (error) { - await saveLogs(bootstrap); + await bootstrap.saveLogs(); throw error; } finally { - await app.close(); + await app?.close(); await bootstrap.teardown(); } })(); diff --git a/ts/test-mock/benchmarks/send_bench.ts b/ts/test-mock/benchmarks/send_bench.ts index a4c999229..bd682bed4 100644 --- a/ts/test-mock/benchmarks/send_bench.ts +++ b/ts/test-mock/benchmarks/send_bench.ts @@ -6,14 +6,8 @@ import assert from 'assert'; import { ReceiptType } from '@signalapp/mock-server'; -import { - Bootstrap, - debug, - saveLogs, - stats, - RUN_COUNT, - DISCARD_COUNT, -} from './fixtures'; +import type { App } from './fixtures'; +import { Bootstrap, debug, stats, RUN_COUNT, DISCARD_COUNT } from './fixtures'; const CONVERSATION_SIZE = 500; // messages @@ -25,9 +19,11 @@ const LAST_MESSAGE = 'start sending messages now'; }); await bootstrap.init(); - const app = await bootstrap.link(); + let app: App | undefined; try { + app = await bootstrap.link(); + const { server, contacts, phone, desktop } = bootstrap; const [first] = contacts; @@ -136,10 +132,10 @@ const LAST_MESSAGE = 'start sending messages now'; console.log('stats info=%j', { delta: stats(deltaList, [99, 99.8]) }); } catch (error) { - await saveLogs(bootstrap); + await bootstrap.saveLogs(); throw error; } finally { - await app.close(); + await app?.close(); await bootstrap.teardown(); } })(); diff --git a/ts/test-mock/benchmarks/startup_bench.ts b/ts/test-mock/benchmarks/startup_bench.ts index b164e5b23..0956c03c3 100644 --- a/ts/test-mock/benchmarks/startup_bench.ts +++ b/ts/test-mock/benchmarks/startup_bench.ts @@ -4,7 +4,7 @@ import { ReceiptType } from '@signalapp/mock-server'; -import { debug, Bootstrap, saveLogs, stats, RUN_COUNT } from './fixtures'; +import { debug, Bootstrap, stats, RUN_COUNT } from './fixtures'; const MESSAGE_BATCH_SIZE = 1000; // messages @@ -128,7 +128,7 @@ const ENABLE_RECEIPTS = Boolean(process.env.ENABLE_RECEIPTS); console.log('stats info=%j', { messagesPerSec: stats(messagesPerSec) }); } } catch (error) { - await saveLogs(bootstrap); + await bootstrap.saveLogs(); throw error; } finally { await bootstrap.teardown(); diff --git a/ts/test-mock/benchmarks/storage_sync_bench.ts b/ts/test-mock/benchmarks/storage_sync_bench.ts index 8149ebc8b..6d4a24ea9 100644 --- a/ts/test-mock/benchmarks/storage_sync_bench.ts +++ b/ts/test-mock/benchmarks/storage_sync_bench.ts @@ -4,7 +4,8 @@ import { StorageState } from '@signalapp/mock-server'; -import { Bootstrap, saveLogs } from './fixtures'; +import type { App } from './fixtures'; +import { Bootstrap } from './fixtures'; const CONTACT_COUNT = 1000; @@ -43,8 +44,9 @@ const CONTACT_COUNT = 1000; await phone.setStorageState(state); const start = Date.now(); - const app = await bootstrap.link(); + let app: App | undefined; try { + app = await bootstrap.link(); const window = await app.getWindow(); const leftPane = window.locator('.left-pane-wrapper'); @@ -58,10 +60,10 @@ const CONTACT_COUNT = 1000; const duration = Date.now() - start; console.log(`Took: ${(duration / 1000).toFixed(2)} seconds`); } catch (error) { - await saveLogs(bootstrap); + await bootstrap.saveLogs(); throw error; } finally { - await app.close(); + await app?.close(); await bootstrap.teardown(); } })(); diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index acc836812..181e5590f 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -8,7 +8,7 @@ import os from 'os'; import createDebug from 'debug'; import type { Device, PrimaryDevice } from '@signalapp/mock-server'; -import { Server, loadCertificates } from '@signalapp/mock-server'; +import { Server, UUIDKind, loadCertificates } from '@signalapp/mock-server'; import { MAX_READ_KEYS as MAX_STORAGE_READ_KEYS } from '../services/storageConstants'; import * as durations from '../util/durations'; import { App } from './playwright'; @@ -156,7 +156,7 @@ export class Bootstrap { ); this.privPhone = await this.server.createPrimaryDevice({ - profileName: 'Mock', + profileName: 'Myself', contacts: this.contacts, }); @@ -206,10 +206,12 @@ export class Bootstrap { await this.phone.addSingleUseKey(this.desktop, desktopKey); for (const contact of this.contacts) { - // eslint-disable-next-line no-await-in-loop - const contactKey = await this.desktop.popSingleUseKey(); - // eslint-disable-next-line no-await-in-loop - await contact.addSingleUseKey(this.desktop, contactKey); + for (const uuidKind of [UUIDKind.ACI, UUIDKind.PNI]) { + // eslint-disable-next-line no-await-in-loop + const contactKey = await this.desktop.popSingleUseKey(uuidKind); + // eslint-disable-next-line no-await-in-loop + await contact.addSingleUseKey(this.desktop, contactKey, uuidKind); + } } await this.phone.waitForSync(this.desktop); @@ -254,6 +256,25 @@ export class Bootstrap { return result; } + public async saveLogs(): Promise { + const { ARTIFACTS_DIR } = process.env; + if (!ARTIFACTS_DIR) { + // eslint-disable-next-line no-console + console.error('Not saving logs. Please set ARTIFACTS_DIR env variable'); + return; + } + + await fs.mkdir(ARTIFACTS_DIR, { recursive: true }); + + const outDir = await fs.mkdtemp(path.join(ARTIFACTS_DIR, 'logs-')); + + // eslint-disable-next-line no-console + console.error(`Saving logs to ${outDir}`); + + const { logsDir } = this; + await fs.rename(logsDir, path.join(outDir)); + } + // // Getters // @@ -299,13 +320,19 @@ export class Bootstrap { storageProfile: 'mock', serverUrl: url, storageUrl: url, - directoryUrl: url, cdn: { '0': url, '2': url, }, updatesEnabled: false, + directoryVersion: 3, + directoryV3Url: url, + directoryV3MRENCLAVE: + '51133fecb3fa18aaf0c8f64cb763656d3272d9faaacdb26ae7df082e414fb142', + directoryV3Root: + '-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n', + ...this.options.extraConfig, }); } diff --git a/ts/test-mock/gv2/accept_invite_test.ts b/ts/test-mock/gv2/accept_invite_test.ts new file mode 100644 index 000000000..05c866fef --- /dev/null +++ b/ts/test-mock/gv2/accept_invite_test.ts @@ -0,0 +1,240 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import type { Group } from '@signalapp/mock-server'; +import { UUIDKind } from '@signalapp/mock-server'; +import createDebug from 'debug'; + +import * as durations from '../../util/durations'; +import { Bootstrap } from '../bootstrap'; +import type { App } from '../bootstrap'; + +export const debug = createDebug('mock:test:gv2'); + +describe('gv2', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let group: Group; + + beforeEach(async () => { + bootstrap = new Bootstrap(); + await bootstrap.init(); + + const { contacts } = bootstrap; + + const [first, second] = contacts; + + group = await first.createGroup({ + title: 'Invite by PNI', + members: [first, second], + }); + + app = await bootstrap.link(); + + const { desktop } = bootstrap; + + group = await first.inviteToGroup(group, desktop, { + uuidKind: UUIDKind.PNI, + }); + + // Verify that created group has pending member + assert.strictEqual(group.state?.members?.length, 2); + assert(!group.getMemberByUUID(desktop.uuid)); + assert(!group.getMemberByUUID(desktop.pni)); + assert(!group.getPendingMemberByUUID(desktop.uuid)); + assert(group.getPendingMemberByUUID(desktop.pni)); + + const window = await app.getWindow(); + + const leftPane = window.locator('.left-pane-wrapper'); + + debug('Opening group'); + await leftPane + .locator( + '_react=ConversationListItem' + + `[title = ${JSON.stringify(group.title)}]` + ) + .click(); + }); + + afterEach(async function after() { + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('should accept PNI invite and modify the group state', async () => { + const { phone, contacts, desktop } = bootstrap; + const [first, second] = contacts; + + const window = await app.getWindow(); + + const conversationStack = window.locator('.conversation-stack'); + + debug('Accepting'); + await conversationStack + .locator('.module-message-request-actions button >> "Accept"') + .click(); + + group = await phone.waitForGroupUpdate(group); + assert.strictEqual(group.revision, 2); + assert.strictEqual(group.state?.members?.length, 3); + assert(group.getMemberByUUID(desktop.uuid)); + assert(!group.getMemberByUUID(desktop.pni)); + assert(!group.getPendingMemberByUUID(desktop.uuid)); + assert(!group.getPendingMemberByUUID(desktop.pni)); + + debug('Checking that notifications are present'); + await window + .locator(`"${first.profileName} invited you to the group."`) + .waitFor(); + await window + .locator( + `"You accepted an invitation to the group from ${first.profileName}."` + ) + .waitFor(); + + debug('Invite PNI again'); + group = await second.inviteToGroup(group, desktop, { + uuidKind: UUIDKind.PNI, + }); + assert(group.getMemberByUUID(desktop.uuid)); + assert(group.getPendingMemberByUUID(desktop.pni)); + + await window + .locator(`"${second.profileName} invited you to the group."`) + .waitFor(); + + debug('Verify that message request state is not visible'); + await conversationStack + .locator('.module-message-request-actions button >> "Accept"') + .waitFor({ state: 'hidden' }); + + debug('Leave the group through settings'); + + await conversationStack + .locator('button.module-ConversationHeader__button--more') + .click(); + + await conversationStack + .locator('.react-contextmenu-item >> "Group settings"') + .click(); + + await conversationStack + .locator('.conversation-details-panel >> "Leave group"') + .click(); + + await window.locator('.module-Modal button >> "Leave"').click(); + + debug('Waiting for final group update'); + group = await phone.waitForGroupUpdate(group); + assert.strictEqual(group.revision, 4); + assert.strictEqual(group.state?.members?.length, 2); + assert(!group.getMemberByUUID(desktop.uuid)); + assert(!group.getMemberByUUID(desktop.pni)); + assert(!group.getPendingMemberByUUID(desktop.uuid)); + assert(group.getPendingMemberByUUID(desktop.pni)); + }); + + it('should decline PNI invite and modify the group state', async () => { + const { phone, desktop } = bootstrap; + + const window = await app.getWindow(); + + const conversationStack = window.locator('.conversation-stack'); + + debug('Declining'); + await conversationStack + .locator('.module-message-request-actions button >> "Delete"') + .click(); + + debug('waiting for confirmation modal'); + await window.locator('.module-Modal button >> "Delete and Leave"').click(); + + group = await phone.waitForGroupUpdate(group); + assert.strictEqual(group.revision, 2); + assert.strictEqual(group.state?.members?.length, 2); + assert(!group.getMemberByUUID(desktop.uuid)); + assert(!group.getMemberByUUID(desktop.pni)); + assert(!group.getPendingMemberByUUID(desktop.uuid)); + assert(!group.getPendingMemberByUUID(desktop.pni)); + }); + + it('should accept ACI invite with extra PNI on the invite list', async () => { + const { phone, contacts, desktop } = bootstrap; + const [first, second] = contacts; + + const window = await app.getWindow(); + + debug('Sending another invite'); + + // Invite ACI from another contact + group = await second.inviteToGroup(group, desktop, { + uuidKind: UUIDKind.ACI, + }); + + const conversationStack = window.locator('.conversation-stack'); + + debug('Accepting'); + await conversationStack + .locator('.module-message-request-actions button >> "Accept"') + .click(); + + debug('Verifying notifications'); + await window + .locator(`"${first.profileName} invited you to the group."`) + .waitFor(); + await window.locator('"You were invited to the group."').waitFor(); + await window + .locator( + `"You accepted an invitation to the group from ${second.profileName}."` + ) + .waitFor(); + + group = await phone.waitForGroupUpdate(group); + assert.strictEqual(group.revision, 3); + assert.strictEqual(group.state?.members?.length, 3); + assert(group.getMemberByUUID(desktop.uuid)); + assert(!group.getMemberByUUID(desktop.pni)); + assert(!group.getPendingMemberByUUID(desktop.uuid)); + assert(group.getPendingMemberByUUID(desktop.pni)); + }); + + it('should decline ACI invite with extra PNI on the invite list', async () => { + const { phone, contacts, desktop } = bootstrap; + const [, second] = contacts; + + const window = await app.getWindow(); + + debug('Sending another invite'); + + // Invite ACI from another contact + group = await second.inviteToGroup(group, desktop, { + uuidKind: UUIDKind.ACI, + }); + + const conversationStack = window.locator('.conversation-stack'); + + debug('Declining'); + await conversationStack + .locator('.module-message-request-actions button >> "Delete"') + .click(); + + debug('waiting for confirmation modal'); + await window.locator('.module-Modal button >> "Delete and Leave"').click(); + + group = await phone.waitForGroupUpdate(group); + assert.strictEqual(group.revision, 3); + assert.strictEqual(group.state?.members?.length, 2); + assert(!group.getMemberByUUID(desktop.uuid)); + assert(!group.getMemberByUUID(desktop.pni)); + assert(!group.getPendingMemberByUUID(desktop.uuid)); + assert(group.getPendingMemberByUUID(desktop.pni)); + }); +}); diff --git a/ts/test-mock/gv2/create_test.ts b/ts/test-mock/gv2/create_test.ts index 82d289d58..2db101256 100644 --- a/ts/test-mock/gv2/create_test.ts +++ b/ts/test-mock/gv2/create_test.ts @@ -63,7 +63,11 @@ describe('gv2', function needsName() { app = await bootstrap.link(); }); - afterEach(async () => { + afterEach(async function after() { + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/archive_test.ts b/ts/test-mock/storage/archive_test.ts index 435d0689b..2f08fe51f 100644 --- a/ts/test-mock/storage/archive_test.ts +++ b/ts/test-mock/storage/archive_test.ts @@ -17,7 +17,15 @@ describe('storage service', function needsName() { ({ bootstrap, app } = await initStorage()); }); - afterEach(async () => { + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/drop_test.ts b/ts/test-mock/storage/drop_test.ts index e1e6ff3ce..d89fcd6af 100644 --- a/ts/test-mock/storage/drop_test.ts +++ b/ts/test-mock/storage/drop_test.ts @@ -20,7 +20,15 @@ describe('storage service', function needsName() { ({ bootstrap, app } = await initStorage()); }); - afterEach(async () => { + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/fixtures.ts b/ts/test-mock/storage/fixtures.ts index 64011835a..b47974b58 100644 --- a/ts/test-mock/storage/fixtures.ts +++ b/ts/test-mock/storage/fixtures.ts @@ -39,63 +39,68 @@ export async function initStorage( await bootstrap.init(); - // Populate storage service - const { contacts, phone } = bootstrap; + try { + // Populate storage service + const { contacts, phone } = bootstrap; - const [firstContact] = contacts; + const [firstContact] = contacts; - const members = [...contacts].slice(0, GROUP_SIZE); + const members = [...contacts].slice(0, GROUP_SIZE); - const group = await phone.createGroup({ - title: 'Mock Group', - members: [phone, ...members], - }); - - let state = StorageState.getEmpty(); - - state = state.updateAccount({ - profileKey: phone.profileKey.serialize(), - e164: phone.device.number, - }); - - state = state - .addGroup(group, { - whitelisted: true, - }) - .pinGroup(group); - - for (const contact of contacts) { - state = state.addContact(contact, { - identityState: Proto.ContactRecord.IdentityState.VERIFIED, - whitelisted: true, - - identityKey: contact.publicKey.serialize(), - profileKey: contact.profileKey.serialize(), + const group = await phone.createGroup({ + title: 'Mock Group', + members: [phone, ...members], }); + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + e164: phone.device.number, + }); + + state = state + .addGroup(group, { + whitelisted: true, + }) + .pinGroup(group); + + for (const contact of contacts) { + state = state.addContact(contact, { + identityState: Proto.ContactRecord.IdentityState.VERIFIED, + whitelisted: true, + + identityKey: contact.publicKey.serialize(), + profileKey: contact.profileKey.serialize(), + }); + } + + state = state.pin(firstContact); + + await phone.setStorageState(state); + + // Link new device + const app = await bootstrap.link(); + + const { desktop } = bootstrap; + + // Send a message to the group and the first contact + const contactSend = contacts[0].sendText(desktop, 'hello from contact', { + timestamp: bootstrap.getTimestamp(), + sealed: true, + }); + + const groupSend = members[0].sendText(desktop, 'hello in group', { + timestamp: bootstrap.getTimestamp(), + sealed: true, + group, + }); + + await Promise.all([contactSend, groupSend]); + + return { bootstrap, app, group, members }; + } catch (error) { + await bootstrap.saveLogs(); + throw error; } - - state = state.pin(firstContact); - - await phone.setStorageState(state); - - // Link new device - const app = await bootstrap.link(); - - const { desktop } = bootstrap; - - // Send a message to the group and the first contact - const contactSend = contacts[0].sendText(desktop, 'hello from contact', { - timestamp: bootstrap.getTimestamp(), - sealed: true, - }); - - const groupSend = members[0].sendText(desktop, 'hello in group', { - timestamp: bootstrap.getTimestamp(), - sealed: true, - group, - }); - - await Promise.all([contactSend, groupSend]); - - return { bootstrap, app, group, members }; } diff --git a/ts/test-mock/storage/max_read_keys_test.ts b/ts/test-mock/storage/max_read_keys_test.ts index d790c9e37..6d45bd68a 100644 --- a/ts/test-mock/storage/max_read_keys_test.ts +++ b/ts/test-mock/storage/max_read_keys_test.ts @@ -22,7 +22,15 @@ describe('storage service', function needsName() { ({ bootstrap, app } = await initStorage()); }); - afterEach(async () => { + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/message_request_test.ts b/ts/test-mock/storage/message_request_test.ts index 78ff48d4d..9c0543aac 100644 --- a/ts/test-mock/storage/message_request_test.ts +++ b/ts/test-mock/storage/message_request_test.ts @@ -17,7 +17,15 @@ describe('storage service', function needsName() { ({ bootstrap, app } = await initStorage()); }); - afterEach(async () => { + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/pin_unpin_test.ts b/ts/test-mock/storage/pin_unpin_test.ts index 73047b22a..cc2cdddf4 100644 --- a/ts/test-mock/storage/pin_unpin_test.ts +++ b/ts/test-mock/storage/pin_unpin_test.ts @@ -21,7 +21,15 @@ describe('storage service', function needsName() { ({ bootstrap, app, group } = await initStorage()); }); - afterEach(async () => { + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + await app.close(); await bootstrap.teardown(); }); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 719598543..f1f17200e 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -5,7 +5,7 @@ import PQueue from 'p-queue'; import { omit } from 'lodash'; import EventTarget from './EventTarget'; -import type { WebAPIType, GroupCredentialType } from './WebAPI'; +import type { WebAPIType } from './WebAPI'; import { HTTPError } from './Errors'; import type { KeyPairType } from './Types.d'; import ProvisioningCipher from './ProvisioningCipher'; @@ -755,14 +755,6 @@ export default class AccountManager extends EventTarget { }); } - async getGroupCredentials( - startDay: number, - endDay: number, - uuidKind: UUIDKind - ): Promise> { - return this.server.getGroupCredentials(startDay, endDay, uuidKind); - } - // Takes the same object returned by generateKeys async confirmKeys( keys: GeneratedKeysType, diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 196c0dc3a..dfc1654d8 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -759,7 +759,9 @@ export default class MessageReceiver // Proto.Envelope fields type: decoded.type, source: decoded.source || item.source, - sourceUuid: decoded.sourceUuid || item.sourceUuid, + sourceUuid: decoded.sourceUuid + ? UUID.cast(decoded.sourceUuid) + : item.sourceUuid, sourceDevice: decoded.sourceDevice || item.sourceDevice, destinationUuid: new UUID( decoded.destinationUuid || item.destinationUuid || ourUuid.toString() diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 7b5d25053..9428de186 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -3,7 +3,7 @@ import type { SignalService as Proto } from '../protobuf'; import type { IncomingWebSocketRequest } from './WebsocketResources'; -import type { UUID } from '../types/UUID'; +import type { UUID, UUIDStringType } from '../types/UUID'; import type { TextAttachmentType } from '../types/Attachment'; import { GiftBadgeStates } from '../components/conversation/Message'; import { MIMEType } from '../types/MIME'; @@ -84,7 +84,7 @@ export type ProcessedEnvelope = Readonly<{ // Mostly from Proto.Envelope except for null/undefined type: Proto.Envelope.Type; source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; sourceDevice?: number; destinationUuid: UUID; timestamp: number; diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index c87b5ccdf..65424bfcd 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -490,7 +490,7 @@ const URL_CALLS = { directoryAuthV2: 'v2/directory/auth', discovery: 'v1/discovery', getGroupAvatarUpload: 'v1/groups/avatar/form', - getGroupCredentials: 'v1/certificate/group', + getGroupCredentials: 'v1/certificate/auth/group', getIceServers: 'v1/accounts/turn', getStickerPackUpload: 'v1/sticker/pack/form', groupLog: 'v1/groups/logs', @@ -718,6 +718,9 @@ export type ProfileType = Readonly<{ unrestrictedUnidentifiedAccess?: string; uuid?: string; credential?: string; + + // Only present when `credentialType` is `pni` + pniCredential?: string; capabilities?: CapabilitiesType; paymentAddress?: string; badges?: unknown; @@ -769,7 +772,7 @@ export type GetUuidsForE164sV2OptionsType = Readonly<{ type GetProfileCommonOptionsType = Readonly< { userLanguages: ReadonlyArray; - credentialType?: 'pni' | 'profileKey'; + credentialType?: 'pni' | 'expiringProfileKey'; } & ( | { profileKeyVersion?: undefined; @@ -792,6 +795,11 @@ export type GetProfileUnauthOptionsType = GetProfileCommonOptionsType & accessKey: string; }>; +export type GetGroupCredentialsOptionsType = Readonly<{ + startDayInMs: number; + endDayInMs: number; +}>; + export type WebAPIType = { startRegistration(): unknown; finishRegistration(baton: unknown): void; @@ -819,9 +827,7 @@ export type WebAPIType = { ) => Promise; getGroupAvatar: (key: string) => Promise; getGroupCredentials: ( - startDay: number, - endDay: number, - uuidKind: UUIDKind + options: GetGroupCredentialsOptionsType ) => Promise>; getGroupExternalCredential: ( options: GroupCredentialsType @@ -1580,7 +1586,7 @@ export function initialize({ { profileKeyVersion, profileKeyCredentialRequest, - credentialType = 'profileKey', + credentialType = 'expiringProfileKey', }: GetProfileCommonOptionsType ) { let profileUrl = `/${identifier}`; @@ -2509,14 +2515,17 @@ export function initialize({ credentials: Array; }; - async function getGroupCredentials( - startDay: number, - endDay: number, - uuidKind: UUIDKind - ): Promise> { + async function getGroupCredentials({ + startDayInMs, + endDayInMs, + }: GetGroupCredentialsOptionsType): Promise> { + const startDayInSeconds = startDayInMs / durations.SECOND; + const endDayInSeconds = endDayInMs / durations.SECOND; const response = (await _ajax({ call: 'getGroupCredentials', - urlParameters: `/${startDay}/${endDay}?${uuidKindToQuery(uuidKind)}`, + urlParameters: + `?redemptionStartSeconds=${startDayInSeconds}&` + + `redemptionEndSeconds=${endDayInSeconds}`, httpType: 'GET', responseType: 'json', })) as CredentialResponseType; diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 56da35547..35e793ea6 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -5,6 +5,7 @@ import type { PublicKey } from '@signalapp/libsignal-client'; import type { SignalService as Proto } from '../protobuf'; +import type { UUIDStringType } from '../types/UUID'; import type { ProcessedEnvelope, ProcessedDataMessage, @@ -129,7 +130,7 @@ export type DeliveryEventData = Readonly<{ timestamp: number; envelopeTimestamp: number; source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; sourceDevice?: number; }>; @@ -166,7 +167,7 @@ export class DecryptionErrorEvent extends ConfirmableEvent { export type RetryRequestEventData = Readonly<{ groupId?: string; ratchetKey?: PublicKey; - requesterUuid: string; + requesterUuid: UUIDStringType; requesterDevice: number; senderDevice: number; sentAt: number; @@ -204,7 +205,7 @@ export class SentEvent extends ConfirmableEvent { export type ProfileKeyUpdateData = Readonly<{ source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; profileKey: string; }>; @@ -219,7 +220,7 @@ export class ProfileKeyUpdateEvent extends ConfirmableEvent { export type MessageEventData = Readonly<{ source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; sourceDevice?: number; timestamp: number; serverGuid?: string; @@ -243,7 +244,7 @@ export type ReadOrViewEventData = Readonly<{ timestamp: number; envelopeTimestamp: number; source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; sourceDevice?: number; }>; @@ -276,14 +277,14 @@ export class ConfigurationEvent extends ConfirmableEvent { export type ViewOnceOpenSyncOptions = { source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; timestamp?: number; }; export class ViewOnceOpenSyncEvent extends ConfirmableEvent { public readonly source?: string; - public readonly sourceUuid?: string; + public readonly sourceUuid?: UUIDStringType; public readonly timestamp?: number; diff --git a/ts/util/getProfile.ts b/ts/util/getProfile.ts index 03189d248..75250d90b 100644 --- a/ts/util/getProfile.ts +++ b/ts/util/getProfile.ts @@ -1,7 +1,10 @@ // Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ProfileKeyCredentialRequestContext } from '@signalapp/libsignal-client/zkgroup'; +import type { + ProfileKeyCredentialRequestContext, + ClientZkProfileOperations, +} from '@signalapp/libsignal-client/zkgroup'; import { SEALED_SENDER } from '../types/SealedSender'; import * as Errors from '../types/errors'; import type { @@ -11,12 +14,15 @@ import type { import { HTTPError } from '../textsecure/Errors'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; +import { UUIDKind } from '../types/UUID'; import * as Bytes from '../Bytes'; import { trimForDisplay, verifyAccessKey, decryptProfile } from '../Crypto'; import { generateProfileKeyCredentialRequest, + generatePNICredentialRequest, getClientZkProfileOperations, handleProfileKeyCredential, + handleProfileKeyPNICredential, } from './zkgroup'; import { isMe } from './whatTypeOfConversation'; import type { ConversationModel } from '../models/conversations'; @@ -25,6 +31,68 @@ import { getUserLanguages } from './userLanguages'; import { parseBadgesFromServer } from '../badges/parseBadgesFromServer'; import { strictAssert } from './assert'; +async function maybeGetPNICredential( + c: ConversationModel, + { + clientZkProfileCipher, + profileKey, + profileKeyVersion, + userLanguages, + }: { + clientZkProfileCipher: ClientZkProfileOperations; + profileKey: string; + profileKeyVersion: string; + userLanguages: ReadonlyArray; + } +): Promise { + // Already present and up-to-date + if (c.get('pniCredential')) { + return; + } + strictAssert(isMe(c.attributes), 'Has to fetch PNI credential for ourselves'); + + log.info('maybeGetPNICredential: requesting PNI credential'); + + const { storage, messaging } = window.textsecure; + strictAssert( + messaging, + 'maybeGetPNICredential: window.textsecure.messaging not available' + ); + + const ourACI = storage.user.getCheckedUuid(UUIDKind.ACI); + const ourPNI = storage.user.getCheckedUuid(UUIDKind.PNI); + + const { + requestHex: profileKeyCredentialRequestHex, + context: profileCredentialRequestContext, + } = generatePNICredentialRequest( + clientZkProfileCipher, + ourACI.toString(), + ourPNI.toString(), + profileKey + ); + + const profile = await messaging.getProfile(ourACI, { + userLanguages, + profileKeyVersion, + profileKeyCredentialRequest: profileKeyCredentialRequestHex, + credentialType: 'pni', + }); + + strictAssert( + profile.pniCredential, + 'We must get the credential for ourselves' + ); + const pniCredential = handleProfileKeyPNICredential( + clientZkProfileCipher, + profileCredentialRequestContext, + profile.pniCredential + ); + c.set({ pniCredential }); + + log.info('maybeGetPNICredential: updated PNI credential'); +} + async function doGetProfile(c: ConversationModel): Promise { const idForLogging = c.idForLogging(); const { messaging } = window.textsecure; @@ -168,6 +236,22 @@ async function doGetProfile(c: ConversationModel): Promise { } } + if (isMe(c.attributes) && profileKey && profileKeyVersion) { + try { + await maybeGetPNICredential(c, { + clientZkProfileCipher, + profileKey, + profileKeyVersion, + userLanguages, + }); + } catch (error) { + log.warn( + 'getProfile failed to get our own PNI credential', + Errors.toLogFormat(error) + ); + } + } + if (profile.identityKey) { const identityKey = Bytes.fromBase64(profile.identityKey); const changed = await window.textsecure.storage.protocol.saveIdentity( @@ -285,12 +369,15 @@ async function doGetProfile(c: ConversationModel): Promise { if (profileCredentialRequestContext) { if (profile.credential) { - const profileKeyCredential = handleProfileKeyCredential( + const { + credential: profileKeyCredential, + expiration: profileKeyCredentialExpiration, + } = handleProfileKeyCredential( clientZkProfileCipher, profileCredentialRequestContext, profile.credential ); - c.set({ profileKeyCredential }); + c.set({ profileKeyCredential, profileKeyCredentialExpiration }); } else { c.unset('profileKeyCredential'); } diff --git a/ts/util/handleRetry.ts b/ts/util/handleRetry.ts index 08846bdc2..817cdbc66 100644 --- a/ts/util/handleRetry.ts +++ b/ts/util/handleRetry.ts @@ -18,6 +18,7 @@ import { parseIntOrThrow } from './parseIntOrThrow'; import * as RemoteConfig from '../RemoteConfig'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; +import { UUID } from '../types/UUID'; import { ToastDecryptionError } from '../components/ToastDecryptionError'; import { showToast } from './showToast'; import * as Errors from '../types/errors'; @@ -287,7 +288,7 @@ async function sendDistributionMessageOrNullMessage( const group = window.ConversationController.get(groupId); const distributionId = group?.get('senderKeyInfo')?.distributionId; - if (group && !group.hasMember(requesterUuid)) { + if (group && !group.hasMember(new UUID(requesterUuid))) { throw new Error( `sendDistributionMessageOrNullMessage/${logId}: Requester ${requesterUuid} is not a member of ${conversation.idForLogging()}` ); @@ -429,7 +430,7 @@ async function maybeAddSenderKeyDistributionMessage({ }; } - if (!conversation.hasMember(requesterUuid)) { + if (!conversation.hasMember(new UUID(requesterUuid))) { throw new Error( `maybeAddSenderKeyDistributionMessage/${logId}: Recipient ${requesterUuid} is not a member of ${conversation.idForLogging()}` ); diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 9078c7e2c..57d19704a 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -23,6 +23,7 @@ import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; import { UUID } from '../types/UUID'; import { getValue, isEnabled } from '../RemoteConfig'; +import type { UUIDStringType } from '../types/UUID'; import { isRecord } from './isRecord'; import { isOlderThan } from './timestamp'; @@ -80,7 +81,7 @@ const ZERO_ACCESS_KEY = Bytes.toBase64(new Uint8Array(ACCESS_KEY_LENGTH)); export type SenderKeyTargetType = { getGroupId: () => string | undefined; getMembers: () => Array; - hasMember: (id: string) => boolean; + hasMember: (uuid: UUIDStringType) => boolean; idForLogging: () => string; isGroupV2: () => boolean; isValid: () => boolean; @@ -1145,10 +1146,12 @@ function partialDeviceComparator( ); } -function getUuidsFromDevices(devices: Array): Array { - const uuids = new Set(); +function getUuidsFromDevices( + devices: Array +): Array { + const uuids = new Set(); devices.forEach(device => { - uuids.add(device.identifier); + uuids.add(UUID.checkedLookup(device.identifier).toString()); }); return Array.from(uuids); @@ -1160,9 +1163,9 @@ export function _analyzeSenderKeyDevices( isPartialSend?: boolean ): { newToMemberDevices: Array; - newToMemberUuids: Array; + newToMemberUuids: Array; removedFromMemberDevices: Array; - removedFromMemberUuids: Array; + removedFromMemberUuids: Array; } { const newToMemberDevices = differenceWith( devicesForSend, diff --git a/ts/util/zkgroup.ts b/ts/util/zkgroup.ts index 995be17ee..686f0e00f 100644 --- a/ts/util/zkgroup.ts +++ b/ts/util/zkgroup.ts @@ -1,9 +1,12 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ProfileKeyCredentialRequestContext } from '@signalapp/libsignal-client/zkgroup'; +import type { + ProfileKeyCredentialRequestContext, + PniCredentialRequestContext, +} from '@signalapp/libsignal-client/zkgroup'; import { - AuthCredential, + AuthCredentialWithPni, ClientZkAuthOperations, ClientZkGroupCipher, ClientZkProfileOperations, @@ -11,9 +14,12 @@ import { GroupSecretParams, ProfileKey, ProfileKeyCiphertext, - ProfileKeyCredential, + ExpiringProfileKeyCredential, ProfileKeyCredentialPresentation, - ProfileKeyCredentialResponse, + ExpiringProfileKeyCredentialResponse, + PniCredential, + PniCredentialResponse, + PniCredentialPresentation, ServerPublicParams, UuidCiphertext, NotarySignature, @@ -32,26 +38,45 @@ export function decryptGroupBlob( return clientZkGroupCipher.decryptBlob(Buffer.from(ciphertext)); } -export function decryptProfileKeyCredentialPresentation( - clientZkGroupCipher: ClientZkGroupCipher, +export function decodeProfileKeyCredentialPresentation( presentationBuffer: Uint8Array -): { profileKey: Uint8Array; uuid: UUIDStringType } { +): { profileKey: Uint8Array; userId: Uint8Array } { const presentation = new ProfileKeyCredentialPresentation( Buffer.from(presentationBuffer) ); - const uuidCiphertext = presentation.getUuidCiphertext(); - const uuid = clientZkGroupCipher.decryptUuid(uuidCiphertext); + const userId = presentation.getUuidCiphertext().serialize(); + const profileKey = presentation.getProfileKeyCiphertext().serialize(); + + return { + profileKey, + userId, + }; +} + +export function decryptPniCredentialPresentation( + clientZkGroupCipher: ClientZkGroupCipher, + presentationBuffer: Uint8Array +): { profileKey: Uint8Array; pni: UUIDStringType; aci: UUIDStringType } { + const presentation = new PniCredentialPresentation( + Buffer.from(presentationBuffer) + ); + + const pniCiphertext = presentation.getPniCiphertext(); + const aciCiphertext = presentation.getAciCiphertext(); + const aci = clientZkGroupCipher.decryptUuid(aciCiphertext); + const pni = clientZkGroupCipher.decryptUuid(pniCiphertext); const profileKeyCiphertext = presentation.getProfileKeyCiphertext(); const profileKey = clientZkGroupCipher.decryptProfileKey( profileKeyCiphertext, - uuid + aci ); return { profileKey: profileKey.serialize(), - uuid: UUID.cast(uuid), + aci: UUID.cast(aci), + pni: UUID.cast(pni), }; } @@ -129,9 +154,11 @@ export function encryptGroupBlob( export function encryptUuid( clientZkGroupCipher: ClientZkGroupCipher, - uuidPlaintext: UUIDStringType + uuidPlaintext: UUID ): Uint8Array { - const uuidCiphertext = clientZkGroupCipher.encryptUuid(uuidPlaintext); + const uuidCiphertext = clientZkGroupCipher.encryptUuid( + uuidPlaintext.toString() + ); return uuidCiphertext.serialize(); } @@ -158,22 +185,46 @@ export function generateProfileKeyCredentialRequest( }; } +export function generatePNICredentialRequest( + clientZkProfileCipher: ClientZkProfileOperations, + aci: UUIDStringType, + pni: UUIDStringType, + profileKeyBase64: string +): { context: PniCredentialRequestContext; requestHex: string } { + const profileKeyArray = Buffer.from(profileKeyBase64, 'base64'); + const profileKey = new ProfileKey(profileKeyArray); + + const context = clientZkProfileCipher.createPniCredentialRequestContext( + aci, + pni, + profileKey + ); + const request = context.getRequest(); + const requestArray = request.serialize(); + + return { + context, + requestHex: requestArray.toString('hex'), + }; +} + export function getAuthCredentialPresentation( clientZkAuthOperations: ClientZkAuthOperations, authCredentialBase64: string, groupSecretParamsBase64: string ): Uint8Array { - const authCredential = new AuthCredential( + const authCredential = new AuthCredentialWithPni( Buffer.from(authCredentialBase64, 'base64') ); const secretParams = new GroupSecretParams( Buffer.from(groupSecretParamsBase64, 'base64') ); - const presentation = clientZkAuthOperations.createAuthCredentialPresentation( - secretParams, - authCredential - ); + const presentation = + clientZkAuthOperations.createAuthCredentialWithPniPresentation( + secretParams, + authCredential + ); return presentation.serialize(); } @@ -186,7 +237,7 @@ export function createProfileKeyCredentialPresentation( profileKeyCredentialBase64, 'base64' ); - const profileKeyCredential = new ProfileKeyCredential( + const profileKeyCredential = new ExpiringProfileKeyCredential( profileKeyCredentialArray ); const secretParams = new GroupSecretParams( @@ -194,7 +245,7 @@ export function createProfileKeyCredentialPresentation( ); const presentation = - clientZkProfileCipher.createProfileKeyCredentialPresentation( + clientZkProfileCipher.createExpiringProfileKeyCredentialPresentation( secretParams, profileKeyCredential ); @@ -202,6 +253,25 @@ export function createProfileKeyCredentialPresentation( return presentation.serialize(); } +export function createPNICredentialPresentation( + clientZkProfileCipher: ClientZkProfileOperations, + pniCredentialBase64: string, + groupSecretParamsBase64: string +): Uint8Array { + const pniCredentialArray = Buffer.from(pniCredentialBase64, 'base64'); + const pniCredential = new PniCredential(pniCredentialArray); + const secretParams = new GroupSecretParams( + Buffer.from(groupSecretParamsBase64, 'base64') + ); + + const presentation = clientZkProfileCipher.createPniCredentialPresentation( + secretParams, + pniCredential + ); + + return presentation.serialize(); +} + export function getClientZkAuthOperations( serverPublicParamsBase64: string ): ClientZkAuthOperations { @@ -236,15 +306,39 @@ export function handleProfileKeyCredential( clientZkProfileCipher: ClientZkProfileOperations, context: ProfileKeyCredentialRequestContext, responseBase64: string -): string { - const response = new ProfileKeyCredentialResponse( +): { credential: string; expiration: number } { + const response = new ExpiringProfileKeyCredentialResponse( Buffer.from(responseBase64, 'base64') ); const profileKeyCredential = - clientZkProfileCipher.receiveProfileKeyCredential(context, response); + clientZkProfileCipher.receiveExpiringProfileKeyCredential( + context, + response + ); const credentialArray = profileKeyCredential.serialize(); + return { + credential: credentialArray.toString('base64'), + expiration: profileKeyCredential.getExpirationTime().getTime(), + }; +} + +export function handleProfileKeyPNICredential( + clientZkProfileCipher: ClientZkProfileOperations, + context: PniCredentialRequestContext, + responseBase64: string +): string { + const response = new PniCredentialResponse( + Buffer.from(responseBase64, 'base64') + ); + const pniCredential = clientZkProfileCipher.receivePniCredential( + context, + response + ); + + const credentialArray = pniCredential.serialize(); + return credentialArray.toString('base64'); } diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 775268b3c..2f33837df 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -1263,7 +1263,7 @@ export class ConversationView extends window.Backbone.View { const ourUuid = window.textsecure.storage.user.getUuid(UUIDKind.ACI); if ( !isGroup(this.model.attributes) || - (ourUuid && this.model.hasMember(ourUuid.toString())) + (ourUuid && this.model.hasMember(ourUuid)) ) { strictAssert( this.model.throttledGetProfiles !== undefined, diff --git a/yarn.lock b/yarn.lock index 82d4ff00e..bfd62c335 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1745,28 +1745,20 @@ "@react-spring/shared" "~9.4.5" "@react-spring/types" "~9.4.5" -"@signalapp/libsignal-client@0.17.0": - version "0.17.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.17.0.tgz#ffe6763d80f56148b45192bca29deb16f9a0aea8" - integrity sha512-O5bd/BURWnybh6KhRYSO3NmNb1/oySu5yJx5ELy3QsfeFvpMnTkr0/PcXd0MCvRiaoN+/a0TsnywMO43t6Nxsw== +"@signalapp/libsignal-client@0.18.1", "@signalapp/libsignal-client@^0.18.1": + version "0.18.1" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.18.1.tgz#6b499cdcc952f1981c6367f68484cf3275be3b31" + integrity sha512-43NcTYpahImlWHBDaNFmn7QaeXZHkFkTtb4m+ZWgzU0mkS1M8V+orGen2XuDvNiu+9HQmW4Lg7FV1deXhWtIRA== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/libsignal-client@^0.16.0": - version "0.16.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.16.0.tgz#7acba54b7ba05f513cdcf7f555efa1ccc6ce0145" - integrity sha512-/5EzlAcQoQReDomqV6VTtin5tvqvdUxoe8knSiz+L1kcLSlHA0So0zTR9WAdfQQ69t4q69vhaS4pu5yVI28YHA== +"@signalapp/mock-server@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.0.1.tgz#0ecee7a0060181546e6b0c1b8e8c6f361fb2d7fe" + integrity sha512-YB0MIUzW8D1NirKpxxNXgEYuvK/OWbFo3djsBA4GqEUBIsJmdYcd4auHSqV3gKE/eSRoFQ0Z//eJNiqtsHbSEw== dependencies: - node-gyp-build "^4.2.3" - uuid "^8.3.0" - -"@signalapp/mock-server@1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-1.5.1.tgz#e37a4505c037a3e85901cd00443d565cf9c2fe90" - integrity sha512-PqRrLhGPtKoTOeHj/L4tUlNkwXZ8MJMU3G7DaaVRAD+g+bpjpeb/ru73iH35K209wRdn4s7/hGUMaeRd6yKFxA== - dependencies: - "@signalapp/libsignal-client" "^0.16.0" + "@signalapp/libsignal-client" "^0.18.1" debug "^4.3.2" long "^4.0.0" micro "^9.3.4"