From 2c69f2c367c11a08ae4e90298546e44f7940b68b Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 20 Nov 2020 09:30:45 -0800 Subject: [PATCH] Support for GV1 -> GV2 migration --- _locales/en/messages.json | 98 +- js/message_requests.js | 55 +- stylesheets/_mixins.scss | 115 ++ stylesheets/_modules.scss | 224 +++- test/crypto_test.js | 37 + ts/ConversationController.ts | 15 + ts/Crypto.ts | 15 + ts/background.ts | 281 +++-- ts/components/Avatar.tsx | 10 +- ts/components/CompositionArea.tsx | 5 +- ts/components/ConversationListItem.tsx | 6 +- ts/components/ErrorModal.tsx | 2 +- .../GroupV1MigrationDialog.stories.tsx | 97 ++ ts/components/GroupV1MigrationDialog.tsx | 180 +++ ts/components/ModalHost.tsx | 65 ++ .../conversation/GroupV1Migration.stories.tsx | 78 ++ .../conversation/GroupV1Migration.tsx | 100 ++ ts/components/conversation/TimelineItem.tsx | 13 + ts/groups.ts | 1020 ++++++++++++++++- ts/model-types.d.ts | 10 +- ts/models/conversations.ts | 93 +- ts/models/messages.ts | 117 +- ts/services/storageRecordOps.ts | 104 +- ts/sql/Server.ts | 4 +- ts/state/ducks/conversations.ts | 4 +- ts/textsecure.d.ts | 6 + ts/textsecure/MessageReceiver.ts | 144 ++- ts/textsecure/SendMessage.ts | 14 + ts/textsecure/WebAPI.ts | 43 +- ts/util/lint/exceptions.json | 6 +- ts/views/conversation_view.ts | 4 + ts/window.d.ts | 2 +- 32 files changed, 2626 insertions(+), 341 deletions(-) create mode 100644 ts/components/GroupV1MigrationDialog.stories.tsx create mode 100644 ts/components/GroupV1MigrationDialog.tsx create mode 100644 ts/components/ModalHost.tsx create mode 100644 ts/components/conversation/GroupV1Migration.stories.tsx create mode 100644 ts/components/conversation/GroupV1Migration.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index beae50d97..a41171fd6 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3140,7 +3140,7 @@ "message": "Please try again or contact support.", "description": "Description text in pop-up dialog when user-initiated task has gone wrong" }, - "ErrorModal--buttonText": { + "Confirmation--confirm": { "message": "Okay", "description": "Button to dismiss pop-up dialog when user-initiated task has gone wrong" }, @@ -3960,6 +3960,102 @@ } } }, + "GroupV1--Migration--was-upgraded": { + "message": "This group was upgraded to a New Group.", + "description": "Shown in timeline when a legacy group (GV1) is upgraded to a new group (GV2)" + }, + "GroupV1--Migration--learn-more": { + "message": "Learn More", + "description": "Shown on a bubble below a 'group was migrated' timeline notification, or as button on Migrate dialog" + }, + "GroupV1--Migration--migrate": { + "message": "Migrate", + "description": "Shown on Migrate dialog to kick off the process" + }, + "GroupV1--Migration--info--title": { + "message": "What are New Groups?", + "description": "Shown on Learn More popup after GV1 migration" + }, + "GroupV1--Migration--migrate--title": { + "message": "Upgrade to New Group", + "description": "Shown on Migration popup after choosing to migrate group" + }, + "GroupV1--Migration--info--summary": { + "message": "New Groups have features like @mentions and group admins, and will support more features in the future.", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--keep-history": { + "message": "All message history and media has been kept from before the upgrade.", + "description": "Shown on Learn More popup after GV1 migration" + }, + "GroupV1--Migration--migrate--keep-history": { + "message": "All message history and media will be kept from before the upgrade.", + "description": "Shown on Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--invited--many": { + "message": "These members will need to accept an invite to join this group again, and will not receive group messages until they accept:", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--invited--one": { + "message": "This member will need to accept an invite to join this group again, and will not receive group messages until they accept:", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--removed--before--many": { + "message": "These members are not capable of joining New Groups, and will be removed from the group:", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--removed--before--one": { + "message": "This member is not capable of joining New Groups, and will be removed from the group:", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--removed--after--many": { + "message": "These members were not capable of joining New Groups, and were removed from the group:", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--info--removed--after--one": { + "message": "This member was not capable of joining New Groups, and was removed from the group:", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, + "GroupV1--Migration--invited--one": { + "message": "$contact$ couldn’t be added to the New Group and has been invited to join.", + "description": "Shown in timeline when a group is upgraded and one person was invited, instead of added", + "placeholders": { + "contact": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV1--Migration--invited--many": { + "message": "$count$ members couldn’t be added to the New Group and have been invited to join.", + "description": "Shown in timeline when a group is upgraded and some people were invited, instead of added", + "placeholders": { + "contact": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV1--Migration--removed--one": { + "message": "$contact$ was removed from the group.", + "description": "Shown in timeline when a group is upgraded and one person was removed entirely during the upgrade", + "placeholders": { + "contact": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV1--Migration--removed--many": { + "message": "$count$ members were removed from the group.", + "description": "Shown in timeline when a group is upgraded and some people were removed entirely during the upgrade", + "placeholders": { + "contact": { + "content": "$1", + "example": "5" + } + } + }, "close": { "message": "Close", "description": "Generic close label" diff --git a/js/message_requests.js b/js/message_requests.js index 60873b8b6..0e902a83d 100644 --- a/js/message_requests.js +++ b/js/message_requests.js @@ -20,9 +20,7 @@ }); if (syncByE164) { window.log.info( - `Found early message request response for E164 ${conversation.get( - 'e164' - )}` + `Found early message request response for E164 ${conversation.idForLogging()}` ); this.remove(syncByE164); return syncByE164; @@ -35,24 +33,35 @@ }); if (syncByUuid) { window.log.info( - `Found early message request response for UUID ${conversation.get( - 'uuid' - )}` + `Found early message request response for UUID ${conversation.idForLogging()}` ); this.remove(syncByUuid); return syncByUuid; } } + // V1 Group if (conversation.get('groupId')) { const syncByGroupId = this.findWhere({ groupId: conversation.get('groupId'), }); if (syncByGroupId) { window.log.info( - `Found early message request response for GROUP ID ${conversation.get( - 'groupId' - )}` + `Found early message request response for group v1 ID ${conversation.idForLogging()}` + ); + this.remove(syncByGroupId); + return syncByGroupId; + } + } + + // V2 group + if (conversation.get('groupId')) { + const syncByGroupId = this.findWhere({ + groupV2Id: conversation.get('groupId'), + }); + if (syncByGroupId) { + window.log.info( + `Found early message request response for group v2 ID ${conversation.idForLogging()}` ); this.remove(syncByGroupId); return syncByGroupId; @@ -66,19 +75,29 @@ const threadE164 = sync.get('threadE164'); const threadUuid = sync.get('threadUuid'); const groupId = sync.get('groupId'); + const groupV2Id = sync.get('groupV2Id'); - const conversation = groupId - ? ConversationController.get(groupId) - : ConversationController.get( - ConversationController.ensureContactIds({ - e164: threadE164, - uuid: threadUuid, - }) - ); + let conversation; + + // We multiplex between GV1/GV2 groups here, but we don't kick off migrations + if (groupV2Id) { + conversation = ConversationController.get(groupV2Id); + } + if (!conversation && groupId) { + conversation = ConversationController.get(groupId); + } + if (!conversation && (threadE164 || threadUuid)) { + conversation = ConversationController.get( + ConversationController.ensureContactIds({ + e164: threadE164, + uuid: threadUuid, + }) + ); + } if (!conversation) { window.log( - `Received message request response for unknown conversation: ${groupId} ${threadUuid} ${threadE164}` + `Received message request response for unknown conversation: groupv2(${groupV2Id}) group(${groupId}) ${threadUuid} ${threadE164}` ); return; } diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 58c87de0a..24b43748f 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -190,3 +190,118 @@ outline: inherit; text-align: inherit; } + +// Buttons + +@mixin button-primary { + background-color: $ultramarine-ui-light; + + // Note: the background colors here need to match the parent component + @include light-theme { + color: $color-white; + border: 1px solid white; + } + @include dark-theme { + color: $color-white-alpha-90; + border: 1px solid $color-gray-95; + } + + &:hover { + @include mouse-mode { + background-color: mix($color-black, $ultramarine-ui-light, 15%); + } + + @include dark-mouse-mode { + background-color: mix($color-white, $ultramarine-ui-light, 15%); + } + } + + &:active { + @include light-theme { + background-color: mix($color-black, $ultramarine-ui-light, 25%); + } + + @include dark-theme { + background-color: mix($color-white, $ultramarine-ui-light, 25%); + } + } + + &:focus { + @include keyboard-mode { + box-shadow: 0px 0px 0px 3px $ultramarine-ui-light; + } + @include dark-keyboard-mode { + box-shadow: 0px 0px 0px 3px $ultramarine-ui-dark; + } + } +} + +@mixin button-secondary { + @include light-theme { + color: $color-gray-90; + background-color: $color-gray-05; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-65; + } + + &:hover { + @include mouse-mode { + background-color: mix($color-black, $color-gray-05, 15%); + } + + @include dark-mouse-mode { + background-color: mix($color-white, $color-gray-65, 15%); + } + } + + &:active { + @include light-theme { + background-color: mix($color-black, $color-gray-05, 25%); + } + + @include dark-theme { + background-color: mix($color-white, $color-gray-65, 25%); + } + } +} +@mixin button-secondary-blue-text { + @include light-theme { + color: $ultramarine-ui-light; + } + @include dark-theme { + color: $ultramarine-ui-dark; + } +} + +@mixin button-destructive { + @include light-theme { + color: $color-white; + background-color: $color-accent-red; + } + @include dark-theme { + color: $color-white-alpha-90; + background-color: $color-accent-red; + } + + &:hover { + @include mouse-mode { + background-color: mix($color-black, $color-accent-red, 15%); + } + + @include dark-mouse-mode { + background-color: mix($color-white, $color-accent-red, 15%); + } + } + + &:active { + @include light-theme { + background-color: mix($color-black, $color-accent-red, 25%); + } + + @include dark-theme { + background-color: mix($color-white, $color-accent-red, 25%); + } + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index a22f5e783..9a80e0de8 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -9687,6 +9687,219 @@ button.module-image__border-overlay:focus { margin-right: auto; } +// Module: GV1 Migration + +.module-group-v1-migration { + @include font-body-1; + + margin-left: 2em; + margin-right: 2em; + + text-align: center; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-05; + } +} + +.module-group-v1-migration--icon { + @include light-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-05 + ); + } + + height: 20px; + width: 20px; + + margin-left: auto; + margin-right: auto; +} + +.module-group-v1-migration--text { + margin-top: 8px; + margin-bottom: 8px; +} + +.module-group-v1-migration--button { + @include button-reset; + @include font-body-2-bold; + border-radius: 4px; + + padding: 8px; + padding-left: 40px; + padding-right: 40px; + + @include button-primary; + @include button-secondary; + @include button-secondary-blue-text; +} + +// Module: Modal Host + +.module-modal-host__overlay { + background: $color-black-alpha-40; + position: absolute; + + height: 100vh; + width: 100vw; + + left: 0; + top: 0; + + z-index: 2; + + display: flex; + flex-direction: column; + justify-content: center; + + overflow: hidden; + padding: 20px; +} + +// Module: GV1 Migration Dialog + +.module-group-v2-migration-dialog { + @include font-body-1; + border-radius: 8px; + width: 360px; + margin-left: auto; + margin-right: auto; + padding: 20px; + + max-height: 100%; + + display: flex; + flex-direction: column; + + position: relative; + + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-95; + } +} +.module-group-v2-migration-dialog__close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } +} +.module-group-v2-migration-dialog__title { + @include font-title-2; + text-align: center; + margin-bottom: 20px; + + flex-grow: 0; + flex-shrink: 0; +} +.module-group-v2-migration-dialog__scrollable { + overflow-x: scroll; + flex-grow: 1; + flex-shrink: 1; +} +.module-group-v2-migration-dialog__item { + display: flex; + flex-direction: row; + align-items: start; + + margin-bottom: 16px; +} +.module-group-v2-migration-dialog__item__bullet { + width: 4px; + height: 11px; + flex-grow: 0; + flex-shrink: 0; + + margin-top: 5px; + + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-65; + } +} +.module-group-v2-migration-dialog__item__content { + margin-left: 16px; +} +.module-group-v2-migration-dialog__member { + margin-top: 16px; +} +.module-group-v2-migration-dialog__member__name { + margin-left: 6px; +} + +.module-group-v2-migration-dialog__buttons { + text-align: center; + flex-grow: 0; + flex-shrink: 0; + + display: flex; +} +.module-group-v2-migration-dialog__buttons--narrow { + margin-left: auto; + margin-right: auto; + width: 152px; +} +.module-group-v2-migration-dialog__button { + @include button-reset; + @include font-body-1-bold; + + // Start flex basis at zero so text width doesn't affect layout. We want the buttons + // evenly distributed. + flex: 1 1 0px; + + border-radius: 4px; + + padding: 8px; + padding-left: 30px; + padding-right: 30px; + + @include button-primary; + + &:not(:first-of-type) { + margin-left: 16px; + } +} + +.module-group-v2-migration-dialog__button--secondary { + @include button-secondary; +} + // Module: Progress Dialog .module-progress-dialog { @@ -9778,6 +9991,7 @@ button.module-image__border-overlay:focus { } // Module: Group Contact Details + $contact-modal-padding: 18px; .module-contact-modal { @include font-body-2; @@ -9863,10 +10077,10 @@ $contact-modal-padding: 18px; &:focus { @include keyboard-mode { background-color: $color-gray-15; + } - @include dark-theme { - background-color: $color-gray-60; - } + @include dark-keyboard-mode { + background-color: $color-gray-60; } } } @@ -9943,8 +10157,8 @@ $contact-modal-padding: 18px; top: 10px; right: 12px; - width: 16px; - height: 16px; + width: 24px; + height: 24px; @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); diff --git a/test/crypto_test.js b/test/crypto_test.js index e44564077..ada200df2 100644 --- a/test/crypto_test.js +++ b/test/crypto_test.js @@ -19,6 +19,43 @@ describe('Crypto', () => { }); }); + describe('deriveMasterKeyFromGroupV1', () => { + const vectors = [ + { + gv1: '00000000000000000000000000000000', + masterKey: + 'dbde68f4ee9169081f8814eabc65523fea1359235c8cfca32b69e31dce58b039', + }, + { + gv1: '000102030405060708090a0b0c0d0e0f', + masterKey: + '70884f78f07a94480ee36b67a4b5e975e92e4a774561e3df84c9076e3be4b9bf', + }, + { + gv1: '7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f', + masterKey: + 'e69bf7c183b288b4ea5745b7c52b651a61e57769fafde683a6fdf1240f1905f2', + }, + { + gv1: 'ffffffffffffffffffffffffffffffff', + masterKey: + 'dd3a7de23d10f18b64457fbeedc76226c112a730e4b76112e62c36c4432eb37d', + }, + ]; + + vectors.forEach((vector, index) => { + it(`vector ${index}`, async () => { + const gv1 = Signal.Crypto.hexToArrayBuffer(vector.gv1); + const expectedHex = vector.masterKey; + + const actual = await Signal.Crypto.deriveMasterKeyFromGroupV1(gv1); + const actualHex = Signal.Crypto.arrayBufferToHex(actual); + + assert.strictEqual(actualHex, expectedHex); + }); + }); + }); + describe('symmetric encryption', () => { it('roundtrips', async () => { const message = 'this is my message'; diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 68c6992a6..8f99e8da1 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -10,6 +10,7 @@ import { } from './model-types.d'; import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage'; import { ConversationModel } from './models/conversations'; +import { maybeDeriveGroupV2Id } from './groups'; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; @@ -222,6 +223,9 @@ export class ConversationController { } try { + if (conversation.isGroupV1()) { + await maybeDeriveGroupV2Id(conversation); + } await saveConversation(conversation.attributes); } catch (error) { window.log.error( @@ -676,6 +680,12 @@ export class ConversationController { }); } + getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined { + return this._conversations.find( + item => item.get('derivedGroupV2Id') === groupId + ); + } + async loadPromise(): Promise { return this._initialPromise; } @@ -710,6 +720,11 @@ export class ConversationController { await Promise.all( this._conversations.map(async conversation => { try { + const isChanged = await maybeDeriveGroupV2Id(conversation); + if (isChanged) { + updateConversation(conversation.attributes); + } + if (!conversation.get('lastMessage')) { await conversation.updateLastMessage(); } diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 5b60cf463..9c0e203c9 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -59,6 +59,21 @@ export async function deriveStickerPackKey( return concatenateBytes(part1, part2); } +export async function deriveMasterKeyFromGroupV1( + groupV1Id: ArrayBuffer +): Promise { + const salt = getZeroes(32); + const info = bytesFromString('GV2 Migration'); + + const [part1] = await window.libsignal.HKDF.deriveSecrets( + groupV1Id, + salt, + info + ); + + return part1; +} + export async function computeHash(data: ArrayBuffer): Promise { const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data); return arrayBufferToBase64(hash); diff --git a/ts/background.ts b/ts/background.ts index 8d70d1525..2ead62242 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,7 +1,12 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -type WhatIsThis = typeof window.WhatIsThis; +// This allows us to pull in types despite the fact that this is not a module. We can't +// use normal import syntax, nor can we use 'import type' syntax, or this will be turned +// into a module, and we'll get the dreaded 'exports is not defined' error. +// see https://github.com/microsoft/TypeScript/issues/41562 +type DataMessageClass = import('./textsecure.d').DataMessageClass; +type WhatIsThis = import('./window.d').WhatIsThis; // eslint-disable-next-line func-names (async function () { @@ -979,10 +984,6 @@ type WhatIsThis = typeof window.WhatIsThis; if (className.includes('module-main-header__search__input')) { return; } - - if (className.includes('module-contact-modal')) { - return; - } } // These add listeners to document, but we'll run first @@ -1022,10 +1023,22 @@ type WhatIsThis = typeof window.WhatIsThis; return; } - const reactionPicker = document.querySelector('module-reaction-picker'); + const reactionPicker = document.querySelector( + '.module-reaction-picker' + ); if (reactionPicker) { return; } + + const contactModal = document.querySelector('.module-contact-modal'); + if (contactModal) { + return; + } + + const modalHost = document.querySelector('.module-modal-host__overlay'); + if (modalHost) { + return; + } } // Close window.Backbone-based confirmation dialog @@ -1975,22 +1988,21 @@ type WhatIsThis = typeof window.WhatIsThis; } } - // We need to do this after fetching our UUID - const hasRegisteredGV23Support = 'hasRegisteredGV23Support'; - if ( - !window.storage.get(hasRegisteredGV23Support) && - window.textsecure.storage.user.getUuid() - ) { + if (connectCount === 1) { const server = window.WebAPI.connect({ username: USERNAME || OLD_USERNAME, password: PASSWORD, }); try { - await server.registerCapabilities({ 'gv2-3': true }); - window.storage.put(hasRegisteredGV23Support, true); + // Note: we always have to register our capabilities all at once, so we do this + // after connect on every startup + await server.registerCapabilities({ + 'gv2-3': true, + 'gv1-migration': true, + }); } catch (error) { window.log.error( - 'Error: Unable to register support for GV2.', + 'Error: Unable to register our capabilities.', error && error.stack ? error.stack : error ); } @@ -2227,16 +2239,35 @@ type WhatIsThis = typeof window.WhatIsThis; return; } + let conversation; + const senderId = window.ConversationController.ensureContactIds({ e164: sender, uuid: senderUuid, highTrust: true, }); - const conversation = window.ConversationController.get( - groupV2Id || groupId || senderId - ); + + // We multiplex between GV1/GV2 groups here, but we don't kick off migrations + if (groupV2Id) { + conversation = window.ConversationController.get(groupV2Id); + } + if (!conversation && groupId) { + conversation = window.ConversationController.get(groupId); + } + if (!groupV2Id && !groupId && senderId) { + conversation = window.ConversationController.get(senderId); + } + const ourId = window.ConversationController.getOurConversationId(); + if (!senderId) { + window.log.warn('onTyping: ensureContactIds returned falsey senderId!'); + return; + } + if (!ourId) { + window.log.warn("onTyping: Couldn't get our own id!"); + return; + } if (!conversation) { window.log.warn( `onTyping: Did not find conversation for typing indicator (groupv2(${groupV2Id}), group(${groupId}), ${sender}, ${senderUuid})` @@ -2245,8 +2276,7 @@ type WhatIsThis = typeof window.WhatIsThis; } // We drop typing notifications in groups we're not a part of - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (!conversation.isPrivate() && !conversation.hasMember(ourId!)) { + if (!conversation.isPrivate() && !conversation.hasMember(ourId)) { window.log.warn( `Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` ); @@ -2255,12 +2285,10 @@ type WhatIsThis = typeof window.WhatIsThis; conversation.notifyTyping({ isTyping: started, - isMe: ourId === senderId, - sender, - senderUuid, + fromMe: senderId === ourId, senderId, senderDevice, - } as WhatIsThis); + }); } async function onStickerPack(ev: WhatIsThis) { @@ -2552,64 +2580,18 @@ type WhatIsThis = typeof window.WhatIsThis; return confirm(); } - // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: - const getDescriptorForReceived = ({ - message, - source, - sourceUuid, - }: WhatIsThis) => { - if (message.groupV2) { - const { id } = message.groupV2; - const conversationId = window.ConversationController.ensureGroup(id, { - // Note: We don't set active_at, because we don't want the group to show until - // we have information about it beyond these initial details. - // see maybeUpdateGroup(). - groupVersion: 2, - masterKey: message.groupV2.masterKey, - secretParams: message.groupV2.secretParams, - publicParams: message.groupV2.publicParams, - }); - - return { - type: Message.GROUP, - id: conversationId, - }; - } - if (message.group) { - const { id } = message.group; - const fromContactId = window.ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - highTrust: true, - }); - - const conversationId = window.ConversationController.ensureGroup(id, { - addedBy: fromContactId, - }); - - return { - type: Message.GROUP, - id: conversationId, - }; - } - - return { - type: Message.PRIVATE, - id: window.ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - highTrust: true, - }), - }; - }; - // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). function onMessageReceived(event: WhatIsThis) { const { data, confirm } = event; - const messageDescriptor = getDescriptorForReceived(data); + const messageDescriptor = getMessageDescriptor({ + ...data, + // 'message' event: for 1:1 converations, the conversation is same as sender + destination: data.source, + destinationUuid: data.sourceUuid, + }); const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags; // eslint-disable-next-line no-bitwise @@ -2776,15 +2758,50 @@ type WhatIsThis = typeof window.WhatIsThis; } as WhatIsThis); } - // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: - const getDescriptorForSent = ({ + // Works with 'sent' and 'message' data sent from MessageReceiver, with a little massage + // at callsites to make sure both source and destination are populated. + const getMessageDescriptor = ({ message, + source, + sourceUuid, destination, destinationUuid, - }: WhatIsThis) => { + }: { + message: DataMessageClass; + source: string; + sourceUuid: string; + destination: string; + destinationUuid: string; + }): MessageDescriptor => { if (message.groupV2) { const { id } = message.groupV2; + if (!id) { + throw new Error('getMessageDescriptor: GroupV2 data was missing an id'); + } + + // First we check for an existing GroupV2 group + const groupV2 = window.ConversationController.get(id); + if (groupV2) { + return { + type: Message.GROUP, + id: groupV2.id, + }; + } + + // Then check for V1 group with matching derived GV2 id + const groupV1 = window.ConversationController.getByDerivedGroupV2Id(id); + if (groupV1) { + return { + type: Message.GROUP, + id: groupV1.id, + }; + } + + // Finally create the V2 group normally const conversationId = window.ConversationController.ensureGroup(id, { + // Note: We don't set active_at, because we don't want the group to show until + // we have information about it beyond these initial details. + // see maybeUpdateGroup(). groupVersion: 2, masterKey: message.groupV2.masterKey, secretParams: message.groupV2.secretParams, @@ -2797,8 +2814,37 @@ type WhatIsThis = typeof window.WhatIsThis; }; } if (message.group) { - const { id } = message.group; - const conversationId = window.ConversationController.ensureGroup(id); + const { id, derivedGroupV2Id } = message.group; + if (!id) { + throw new Error('getMessageDescriptor: GroupV1 data was missing id'); + } + if (!derivedGroupV2Id) { + window.log.warn( + 'getMessageDescriptor: GroupV1 data was missing derivedGroupV2Id' + ); + } else { + // First we check for an already-migrated GroupV2 group + const migratedGroup = window.ConversationController.get( + derivedGroupV2Id + ); + if (migratedGroup) { + return { + type: Message.GROUP, + id: migratedGroup.id, + }; + } + } + + // If we can't find one, we treat this as a normal GroupV1 group + const fromContactId = window.ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + highTrust: true, + }); + + const conversationId = window.ConversationController.ensureGroup(id, { + addedBy: fromContactId, + }); return { type: Message.GROUP, @@ -2806,13 +2852,20 @@ type WhatIsThis = typeof window.WhatIsThis; }; } + const id = window.ConversationController.ensureContactIds({ + e164: destination, + uuid: destinationUuid, + highTrust: true, + }); + if (!id) { + throw new Error( + 'getMessageDescriptor: ensureContactIds returned falsey id' + ); + } + return { type: Message.PRIVATE, - id: window.ConversationController.ensureContactIds({ - e164: destination, - uuid: destinationUuid, - highTrust: true, - }), + id, }; }; @@ -2822,7 +2875,12 @@ type WhatIsThis = typeof window.WhatIsThis; function onSentMessage(event: WhatIsThis) { const { data, confirm } = event; - const messageDescriptor = getDescriptorForSent(data); + const messageDescriptor = getMessageDescriptor({ + ...data, + // 'sent' event: the sender is always us! + source: window.textsecure.storage.user.getNumber(), + sourceUuid: window.textsecure.storage.user.getUuid(), + }); const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags; // eslint-disable-next-line no-bitwise @@ -2885,7 +2943,15 @@ type WhatIsThis = typeof window.WhatIsThis; return Promise.resolve(); } - function initIncomingMessage(data: WhatIsThis, descriptor: WhatIsThis) { + type MessageDescriptor = { + type: 'private' | 'group'; + id: string; + }; + + function initIncomingMessage( + data: WhatIsThis, + descriptor: MessageDescriptor + ) { return new window.Whisper.Message({ source: data.source, sourceUuid: data.sourceUuid, @@ -2998,12 +3064,16 @@ type WhatIsThis = typeof window.WhatIsThis; return Promise.resolve(); } const envelope = ev.proto; + const id = window.ConversationController.ensureContactIds({ + e164: envelope.source, + uuid: envelope.sourceUuid, + }); + if (!id) { + throw new Error('onError: ensureContactIds returned falsey id!'); + } const message = initIncomingMessage(envelope, { type: Message.PRIVATE, - id: window.ConversationController.ensureContactIds({ - e164: envelope.source, - uuid: envelope.sourceUuid, - }), + id, }); const conversationId = message.get('conversationId'); @@ -3141,18 +3211,29 @@ type WhatIsThis = typeof window.WhatIsThis; async function onMessageRequestResponse(ev: WhatIsThis) { ev.confirm(); - const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev; - - const args = { + const { threadE164, threadUuid, groupId, + groupV2Id, + messageRequestResponseType, + } = ev; + + window.log.info('onMessageRequestResponse', { + threadE164, + threadUuid, + groupId: `group(${groupId})`, + groupV2Id: `groupv2(${groupV2Id})`, + messageRequestResponseType, + }); + + const sync = window.Whisper.MessageRequests.add({ + threadE164, + threadUuid, + groupId, + groupV2Id, type: messageRequestResponseType, - }; - - window.log.info('message request response', args); - - const sync = window.Whisper.MessageRequests.add(args); + }); window.Whisper.MessageRequests.onResponse(sync); } diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index daa61d22f..af3eb871a 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -86,15 +86,9 @@ export class Avatar extends React.Component { } public renderNoImage(): JSX.Element { - const { - conversationType, - name, - noteToSelf, - profileName, - size, - } = this.props; + const { conversationType, noteToSelf, size, title } = this.props; - const initials = getInitials(name || profileName); + const initials = getInitials(title); const isGroup = conversationType === 'group'; if (noteToSelf) { diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index e2265918c..7c1079e03 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -337,8 +337,9 @@ export const CompositionArea = ({ }, [setLarge]); if ( - messageRequestsEnabled && - (!acceptedMessageRequest || isBlocked || areWePending) + isBlocked || + areWePending || + (messageRequestsEnabled && !acceptedMessageRequest) ) { return ( { isUnread(): boolean { const { markedUnread, unreadCount } = this.props; - return (isNumber(unreadCount) && unreadCount > 0) || markedUnread; + return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread); } public renderUnread(): JSX.Element | null { diff --git a/ts/components/ErrorModal.tsx b/ts/components/ErrorModal.tsx index 55eef76cd..e56ec2c54 100644 --- a/ts/components/ErrorModal.tsx +++ b/ts/components/ErrorModal.tsx @@ -41,7 +41,7 @@ export const ErrorModal = (props: PropsType): JSX.Element => { onClick={onClose} ref={focusRef} > - {buttonText || i18n('ErrorModal--buttonText')} + {buttonText || i18n('Confirmation--confirm')} diff --git a/ts/components/GroupV1MigrationDialog.stories.tsx b/ts/components/GroupV1MigrationDialog.stories.tsx new file mode 100644 index 000000000..8ea87f206 --- /dev/null +++ b/ts/components/GroupV1MigrationDialog.stories.tsx @@ -0,0 +1,97 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { isBoolean } from 'lodash'; +import { boolean } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; + +import { GroupV1MigrationDialog, PropsType } from './GroupV1MigrationDialog'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const contact1 = { + title: 'Alice', + number: '+1 (300) 555-000', + id: 'guid-1', + markedUnread: false, + type: 'direct' as const, + lastUpdated: Date.now(), +}; + +const contact2 = { + title: 'Bob', + number: '+1 (300) 555-000', + id: 'guid-1', + markedUnread: false, + type: 'direct' as const, + lastUpdated: Date.now(), +}; + +function booleanOr(value: boolean | undefined, defaultValue: boolean): boolean { + return isBoolean(value) ? value : defaultValue; +} + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + droppedMembers: overrideProps.droppedMembers || [contact1], + hasMigrated: boolean( + 'hasMigrated', + booleanOr(overrideProps.hasMigrated, false) + ), + i18n, + invitedMembers: overrideProps.invitedMembers || [contact2], + learnMore: action('learnMore'), + migrate: action('migrate'), + onClose: action('onClose'), +}); + +const stories = storiesOf('Components/GroupV1MigrationDialog', module); + +stories.add('Not yet migrated, basic', () => { + return ; +}); + +stories.add('Migrated, basic', () => { + return ( + + ); +}); + +stories.add('Not yet migrated, multiple dropped and invited members', () => { + return ( + + ); +}); + +stories.add('Not yet migrated, no members', () => { + return ( + + ); +}); + +stories.add('Not yet migrated, just dropped member', () => { + return ( + + ); +}); diff --git a/ts/components/GroupV1MigrationDialog.tsx b/ts/components/GroupV1MigrationDialog.tsx new file mode 100644 index 000000000..5ec8c137e --- /dev/null +++ b/ts/components/GroupV1MigrationDialog.tsx @@ -0,0 +1,180 @@ +// Copyright 2019-2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import classNames from 'classnames'; +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { Avatar } from './Avatar'; + +export type ActionSpec = { + text: string; + action: () => unknown; + style?: 'affirmative' | 'negative'; +}; + +type CallbackType = () => unknown; + +export type DataPropsType = { + readonly droppedMembers: Array; + readonly hasMigrated: boolean; + readonly invitedMembers: Array; + readonly learnMore: CallbackType; + readonly migrate: CallbackType; + readonly onClose: CallbackType; +}; + +export type HousekeepingPropsType = { + readonly i18n: LocalizerType; +}; + +export type PropsType = DataPropsType & HousekeepingPropsType; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export const GroupV1MigrationDialog = React.memo((props: PropsType) => { + const { + droppedMembers, + hasMigrated, + i18n, + invitedMembers, + learnMore, + migrate, + onClose, + } = props; + + const title = hasMigrated + ? i18n('GroupV1--Migration--info--title') + : i18n('GroupV1--Migration--migrate--title'); + const keepHistory = hasMigrated + ? i18n('GroupV1--Migration--info--keep-history') + : i18n('GroupV1--Migration--migrate--keep-history'); + const migrationKey = hasMigrated ? 'after' : 'before'; + const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`; + + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} + +function renderMembers( + members: Array, + prefix: string, + i18n: LocalizerType +): React.ReactElement | null { + if (!members.length) { + return null; + } + + const postfix = members.length === 1 ? '--one' : '--many'; + const key = `${prefix}${postfix}`; + + return ( +
+
+
+
{i18n(key)}
+ {members.map(member => ( +
+ {' '} + + {member.title} + +
+ ))} +
+
+ ); +} diff --git a/ts/components/ModalHost.tsx b/ts/components/ModalHost.tsx new file mode 100644 index 000000000..6219f538e --- /dev/null +++ b/ts/components/ModalHost.tsx @@ -0,0 +1,65 @@ +// Copyright 2019-2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +export type PropsType = { + readonly onClose: () => unknown; + readonly children: React.ReactElement; +}; + +export const ModalHost = React.memo(({ onClose, children }: PropsType) => { + const [root, setRoot] = React.useState(null); + + React.useEffect(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + setRoot(div); + + return () => { + document.body.removeChild(div); + setRoot(null); + }; + }, []); + + React.useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + + event.preventDefault(); + event.stopPropagation(); + } + }; + document.addEventListener('keydown', handler); + + return () => { + document.removeEventListener('keydown', handler); + }; + }, [onClose]); + + // This makes it easier to write dialogs to be hosted here; they won't have to worry + // as much about preventing propagation of mouse events. + const handleCancel = React.useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + return root + ? createPortal( +
+ {children} +
, + root + ) + : null; +}); diff --git a/ts/components/conversation/GroupV1Migration.stories.tsx b/ts/components/conversation/GroupV1Migration.stories.tsx new file mode 100644 index 000000000..4aabeec96 --- /dev/null +++ b/ts/components/conversation/GroupV1Migration.stories.tsx @@ -0,0 +1,78 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable-next-line max-classes-per-file */ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { GroupV1Migration, PropsType } from './GroupV1Migration'; + +const i18n = setupI18n('en', enMessages); + +const contact1 = { + title: 'Alice', + number: '+1 (300) 555-000', + id: 'guid-1', + markedUnread: false, + type: 'direct' as const, + lastUpdated: Date.now(), +}; + +const contact2 = { + title: 'Bob', + number: '+1 (300) 555-000', + id: 'guid-2', + markedUnread: false, + type: 'direct' as const, + lastUpdated: Date.now(), +}; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + droppedMembers: overrideProps.droppedMembers || [contact1], + i18n, + invitedMembers: overrideProps.invitedMembers || [contact2], +}); + +const stories = storiesOf('Components/Conversation/GroupV1Migration', module); + +stories.add('Single dropped and single invited member', () => ( + +)); + +stories.add('Multiple dropped and invited members', () => ( + +)); + +stories.add('Just invited members', () => ( + +)); + +stories.add('Just dropped members', () => ( + +)); + +stories.add('No dropped or invited members', () => ( + +)); diff --git a/ts/components/conversation/GroupV1Migration.tsx b/ts/components/conversation/GroupV1Migration.tsx new file mode 100644 index 000000000..cf3a4ff86 --- /dev/null +++ b/ts/components/conversation/GroupV1Migration.tsx @@ -0,0 +1,100 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { LocalizerType } from '../../types/Util'; +import { ConversationType } from '../../state/ducks/conversations'; +import { Intl } from '../Intl'; +import { ContactName } from './ContactName'; +import { ModalHost } from '../ModalHost'; +import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog'; + +export type PropsDataType = { + droppedMembers: Array; + invitedMembers: Array; +}; + +export type PropsHousekeepingType = { + i18n: LocalizerType; +}; + +export type PropsType = PropsDataType & PropsHousekeepingType; + +export function GroupV1Migration(props: PropsType): React.ReactElement { + const { droppedMembers, i18n, invitedMembers } = props; + const [showingDialog, setShowingDialog] = React.useState(false); + + const showDialog = React.useCallback(() => { + setShowingDialog(true); + }, [setShowingDialog]); + + const dismissDialog = React.useCallback(() => { + setShowingDialog(false); + }, [setShowingDialog]); + + return ( +
+
+
+ {i18n('GroupV1--Migration--was-upgraded')} +
+ {renderUsers(invitedMembers, i18n, 'GroupV1--Migration--invited')} + {renderUsers(droppedMembers, i18n, 'GroupV1--Migration--removed')} + + {showingDialog ? ( + + + window.log.warn('GroupV1Migration: Modal called learnMore()') + } + migrate={() => + window.log.warn('GroupV1Migration: Modal called migrate()') + } + onClose={dismissDialog} + /> + + ) : null} +
+ ); +} + +function renderUsers( + members: Array, + i18n: LocalizerType, + keyPrefix: string +): React.ReactElement | null { + if (!members || members.length === 0) { + return null; + } + + const className = 'module-group-v1-migration--text'; + + if (members.length === 1) { + return ( +
+ ]} + /> +
+ ); + } + + return ( +
+ {i18n(`${keyPrefix}--many`, [members.length.toString()])} +
+ ); +} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index cede37e27..0276cac6b 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -42,6 +42,10 @@ import { GroupV2Change, PropsDataType as GroupV2ChangeProps, } from './GroupV2Change'; +import { + GroupV1Migration, + PropsDataType as GroupV1MigrationProps, +} from './GroupV1Migration'; import { SmartContactRendererType } from '../../groupChange'; import { ResetSessionNotification } from './ResetSessionNotification'; import { @@ -85,6 +89,10 @@ type GroupV2ChangeType = { type: 'groupV2Change'; data: GroupV2ChangeProps; }; +type GroupV1MigrationType = { + type: 'groupV1Migration'; + data: GroupV1MigrationProps; +}; type ResetSessionNotificationType = { type: 'resetSessionNotification'; data: null; @@ -97,6 +105,7 @@ type ProfileChangeNotificationType = { export type TimelineItemType = | CallHistoryType | GroupNotificationType + | GroupV1MigrationType | GroupV2ChangeType | LinkNotificationType | MessageType @@ -187,6 +196,10 @@ export class TimelineItem extends React.PureComponent { i18n={i18n} /> ); + } else if (item.type === 'groupV1Migration') { + notification = ( + + ); } else if (item.type === 'resetSessionNotification') { notification = ( diff --git a/ts/groups.ts b/ts/groups.ts index 469a4512c..ecf65cbb3 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -4,6 +4,7 @@ import { compact, Dictionary, + difference, flatten, fromPairs, isNumber, @@ -16,6 +17,7 @@ import { GROUP_CREDENTIALS_KEY, maybeFetchNewCredentials, } from './services/groupCredentialFetcher'; +import dataInterface from './sql/Client'; import { ConversationAttributesType, GroupV2MemberType, @@ -43,6 +45,8 @@ import { arrayBufferToHex, base64ToArrayBuffer, computeHash, + deriveMasterKeyFromGroupV1, + fromEncodedBinaryToArrayBuffer, } from './Crypto'; import { GroupAttributeBlobClass, @@ -53,8 +57,11 @@ import { PendingMemberClass, ProtoBinaryType, } from './textsecure.d'; -import { GroupCredentialsType } from './textsecure/WebAPI'; -import MessageSender from './textsecure/SendMessage'; +import { + GroupCredentialsType, + GroupLogResponseType, +} from './textsecure/WebAPI'; +import MessageSender, { CallbackResultType } from './textsecure/SendMessage'; import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; import { ConversationModel } from './models/conversations'; @@ -141,6 +148,8 @@ export type GroupV2ChangeType = { details: Array; }; +const { updateConversation } = dataInterface; + if (!isNumber(MAX_MESSAGE_SCHEMA)) { throw new Error( 'groups.ts: Unable to capture max message schema from js/modules/types/message' @@ -168,10 +177,205 @@ export const ID_V1_LENGTH = 16; 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 = 0; // Group Modifications +async function uploadAvatar({ + logId, + path, + publicParams, + secretParams, +}: { + logId: string; + path: string; + publicParams: string; + secretParams: string; +}): Promise<{ hash: string; key: string }> { + try { + const clientZkGroupCipher = getClientZkGroupCipher(secretParams); + + const data = await window.Signal.Migrations.readAttachmentData(path); + const hash = await computeHash(data); + + const blob = new window.textsecure.protobuf.GroupAttributeBlob(); + blob.avatar = data; + const blobPlaintext = blob.toArrayBuffer(); + const ciphertext = encryptGroupBlob(clientZkGroupCipher, blobPlaintext); + + const key = await makeRequestWithTemporalRetry({ + logId: `uploadGroupAvatar/${logId}`, + publicParams, + secretParams, + request: (sender, options) => + sender.uploadGroupAvatar(ciphertext, options), + }); + + return { + key, + hash, + }; + } catch (error) { + window.log.warn( + `uploadAvatar/${logId} Failed to upload avatar`, + error.stack + ); + throw error; + } +} + +async function buildGroupProto({ + attributes, +}: { + attributes: ConversationAttributesType; +}): Promise { + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + const logId = `groupv2(${attributes.id})`; + + const { publicParams, secretParams } = attributes; + + if (!publicParams) { + throw new Error( + `buildGroupProto/${logId}: attributes were missing publicParams!` + ); + } + if (!secretParams) { + throw new Error( + `buildGroupProto/${logId}: attributes were missing secretParams!` + ); + } + + const serverPublicParamsBase64 = window.getServerPublicParams(); + const clientZkGroupCipher = getClientZkGroupCipher(secretParams); + const clientZkProfileCipher = getClientZkProfileOperations( + serverPublicParamsBase64 + ); + const proto = new window.textsecure.protobuf.Group(); + + proto.publicKey = base64ToArrayBuffer(publicParams); + proto.version = attributes.revision || 0; + + const titleBlob = new window.textsecure.protobuf.GroupAttributeBlob(); + titleBlob.title = attributes.name; + const titleBlobPlaintext = titleBlob.toArrayBuffer(); + proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext); + + if (attributes.avatar && attributes.avatar.path) { + const { path } = attributes.avatar; + const { key, hash } = await uploadAvatar({ + logId, + path, + publicParams, + secretParams, + }); + + // eslint-disable-next-line no-param-reassign + attributes.avatar.hash = hash; + // eslint-disable-next-line no-param-reassign + attributes.avatar.url = key; + + proto.avatar = key; + } + + if (attributes.expireTimer) { + const timerBlob = new window.textsecure.protobuf.GroupAttributeBlob(); + timerBlob.disappearingMessagesDuration = attributes.expireTimer; + const timerBlobPlaintext = timerBlob.toArrayBuffer(); + proto.disappearingMessagesTimer = encryptGroupBlob( + clientZkGroupCipher, + timerBlobPlaintext + ); + } + + const accessControl = new window.textsecure.protobuf.AccessControl(); + if (attributes.accessControl) { + accessControl.attributes = + attributes.accessControl.attributes || ACCESS_ENUM.MEMBER; + accessControl.members = + attributes.accessControl.members || ACCESS_ENUM.MEMBER; + } else { + accessControl.attributes = ACCESS_ENUM.MEMBER; + accessControl.members = ACCESS_ENUM.MEMBER; + } + proto.accessControl = accessControl; + + proto.members = (attributes.membersV2 || []).map(item => { + const member = new window.textsecure.protobuf.Member(); + + const conversation = window.ConversationController.get(item.conversationId); + if (!conversation) { + throw new Error(`buildGroupProto/${logId}: no conversation for member!`); + } + + const profileKeyCredentialBase64 = conversation.get('profileKeyCredential'); + if (!profileKeyCredentialBase64) { + throw new Error( + `buildGroupProto/${logId}: member was missing profileKeyCredentia!` + ); + } + const presentation = createProfileKeyCredentialPresentation( + clientZkProfileCipher, + profileKeyCredentialBase64, + secretParams + ); + + member.role = item.role || MEMBER_ROLE_ENUM.DEFAULT; + member.presentation = presentation; + + return member; + }); + + const ourConversationId = window.ConversationController.getOurConversationId(); + if (!ourConversationId) { + throw new Error( + `buildGroupProto/${logId}: unable to find our own conversationId!` + ); + } + + const me = window.ConversationController.get(ourConversationId); + if (!me) { + throw new Error( + `buildGroupProto/${logId}: unable to find our own conversation!` + ); + } + + const ourUuid = me.get('uuid'); + if (!ourUuid) { + throw new Error(`buildGroupProto/${logId}: unable to find our own uuid!`); + } + + const ourUuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, ourUuid); + + proto.pendingMembers = (attributes.pendingMembersV2 || []).map(item => { + const pendingMember = new window.textsecure.protobuf.PendingMember(); + const member = new window.textsecure.protobuf.Member(); + + const conversation = window.ConversationController.get(item.conversationId); + if (!conversation) { + 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 uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + member.userId = uuidCipherTextBuffer; + member.role = MEMBER_ROLE_ENUM.DEFAULT; + + pendingMember.member = member; + pendingMember.timestamp = item.timestamp; + pendingMember.addedByUserId = ourUuidCipherTextBuffer; + + return pendingMember; + }); + + return proto; +} + export function buildDisappearingMessagesTimerChange({ expireTimer, group, @@ -291,17 +495,11 @@ export function buildPromoteMemberChange({ export async function uploadGroupChange({ actions, group, - serverPublicParamsBase64, }: { actions: GroupChangeClass.Actions; group: ConversationAttributesType; - serverPublicParamsBase64: string; }): Promise { const logId = idForLogging(group); - const sender = window.textsecure.messaging; - if (!sender) { - throw new Error('textsecure.messaging is not available!'); - } // Ensure we have the credentials we need before attempting GroupsV2 operations await maybeFetchNewCredentials(); @@ -313,43 +511,23 @@ export async function uploadGroupChange({ throw new Error('uploadGroupChange: group was missing publicParams!'); } - const groupCredentials = getCredentialsForToday( - window.storage.get(GROUP_CREDENTIALS_KEY) - ); - - const options = { - authCredentialBase64: groupCredentials.today.credential, - serverPublicParamsBase64, - groupPublicParamsBase64: group.publicParams, - groupSecretParamsBase64: group.secretParams, - }; - - try { - const optionsForToday = getGroupCredentials(options); - const result = await sender.modifyGroup(actions, optionsForToday); - - return result; - } catch (error) { - if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { - window.log.info( - `uploadGroupChange/${logId}: Credential for today failed, failing over to tomorrow...` - ); - const optionsForTomorrow = getGroupCredentials({ - ...options, - authCredentialBase64: groupCredentials.tomorrow.credential, - }); - return sender.modifyGroup(actions, optionsForTomorrow); - } - - throw error; - } + return makeRequestWithTemporalRetry({ + logId: `uploadGroupChange/${logId}`, + publicParams: group.publicParams, + secretParams: group.secretParams, + request: (sender, options) => sender.modifyGroup(actions, options), + }); } // Utility +function idForLogging(group: ConversationAttributesType) { + return `groupv2(${group.groupId})`; +} + export function deriveGroupFields( masterKey: ArrayBuffer -): Record { +): { id: ArrayBuffer; secretParams: ArrayBuffer; publicParams: ArrayBuffer } { const secretParams = deriveGroupSecretParams(masterKey); const publicParams = deriveGroupPublicParams(secretParams); const id = deriveGroupID(secretParams); @@ -441,6 +619,723 @@ export async function fetchMembershipProof({ return response.token; } +// Migrating a group + +export async function hasV1GroupBeenMigrated( + conversation: ConversationModel +): Promise { + const logId = conversation.idForLogging(); + const isGroupV1 = conversation.isGroupV1(); + if (!isGroupV1) { + window.log.warn( + `checkForGV2Existence/${logId}: Called for non-GroupV1 conversation!` + ); + return false; + } + + // Ensure we have the credentials we need before attempting GroupsV2 operations + await maybeFetchNewCredentials(); + + const groupId = conversation.get('groupId'); + if (!groupId) { + throw new Error(`checkForGV2Existence/${logId}: No groupId!`); + } + + const idBuffer = fromEncodedBinaryToArrayBuffer(groupId); + const masterKeyBuffer = await deriveMasterKeyFromGroupV1(idBuffer); + const fields = deriveGroupFields(masterKeyBuffer); + + try { + await makeRequestWithTemporalRetry({ + logId: `getGroup/${logId}`, + publicParams: arrayBufferToBase64(fields.publicParams), + secretParams: arrayBufferToBase64(fields.secretParams), + request: (sender, options) => sender.getGroup(options), + }); + return true; + } catch (error) { + const { code } = error; + return code !== GROUP_NONEXISTENT_CODE && code !== GROUP_ACCESS_DENIED_CODE; + } +} + +export async function maybeDeriveGroupV2Id( + conversation: ConversationModel +): Promise { + const isGroupV1 = conversation.isGroupV1(); + const groupV1Id = conversation.get('groupId'); + const derived = conversation.get('derivedGroupV2Id'); + + if (!isGroupV1 || !groupV1Id || derived) { + return false; + } + + const v1IdBuffer = fromEncodedBinaryToArrayBuffer(groupV1Id); + const masterKeyBuffer = await deriveMasterKeyFromGroupV1(v1IdBuffer); + const fields = deriveGroupFields(masterKeyBuffer); + const derivedGroupV2Id = arrayBufferToBase64(fields.id); + + conversation.set({ + derivedGroupV2Id, + }); + + return true; +} + +type MigratePropsType = { + conversation: ConversationModel; + groupChangeBase64?: string; + newRevision?: number; + receivedAt?: number; + sentAt?: number; +}; + +export async function isGroupEligibleToMigrate( + conversation: ConversationModel +): Promise { + if (!conversation.isGroupV1()) { + return false; + } + + const ourConversationId = window.ConversationController.getOurConversationId(); + const areWeMember = + !conversation.get('left') && + ourConversationId && + conversation.hasMember(ourConversationId); + if (!areWeMember) { + return false; + } + + const members = conversation.get('members') || []; + for (let i = 0, max = members.length; i < max; i += 1) { + const identifier = members[i]; + const contact = window.ConversationController.get(identifier); + + if (!contact) { + return false; + } + if (!contact.get('uuid')) { + return false; + } + } + + return true; +} + +// This is called when the user chooses to migrate a GroupV1. It will update the server, +// then let all members know about the new group. +export async function initiateMigrationToGroupV2( + conversation: ConversationModel +): Promise { + // Ensure we have the credentials we need before attempting GroupsV2 operations + await maybeFetchNewCredentials(); + + try { + await conversation.queueJob(async () => { + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + const ACCESS_ENUM = + window.textsecure.protobuf.AccessControl.AccessRequired; + + const isEligible = isGroupEligibleToMigrate(conversation); + const previousGroupV1Id = conversation.get('groupId'); + + if (!isEligible || !previousGroupV1Id) { + throw new Error( + `initiateMigrationToGroupV2: conversation is not eligible to migrate! ${conversation.idForLogging()}` + ); + } + + const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id); + const masterKeyBuffer = await deriveMasterKeyFromGroupV1(groupV1IdBuffer); + const fields = deriveGroupFields(masterKeyBuffer); + + const groupId = arrayBufferToBase64(fields.id); + const logId = `groupv2(${groupId})`; + window.log.info( + `initiateMigrationToGroupV2/${logId}: Migrating from ${conversation.idForLogging()}` + ); + + const masterKey = arrayBufferToBase64(masterKeyBuffer); + const secretParams = arrayBufferToBase64(fields.secretParams); + const publicParams = arrayBufferToBase64(fields.publicParams); + + const ourConversationId = window.ConversationController.getOurConversationId(); + if (!ourConversationId) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!` + ); + } + + let areWeMember = false; + let areWeInvited = false; + + const now = Date.now(); + + const previousGroupV1Members = conversation.get('members') || []; + const memberLookup: Record = {}; + const membersV2: Array = compact( + await Promise.all( + previousGroupV1Members.map(async e164 => { + const contact = window.ConversationController.get(e164); + + if (!contact) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: membersV2 - missing local contact for ${e164}, skipping.` + ); + } + if (!contact.get('uuid')) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: membersV2 - missing uuid for ${e164}, skipping.` + ); + return null; + } + + if (!contact.get('profileKey')) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: membersV2 - missing profileKey for member ${e164}, skipping.` + ); + return null; + } + + let capabilities = contact.get('capabilities'); + + // Refresh our local data to be sure + if ( + !capabilities || + !capabilities.gv2 || + !capabilities['gv1-migration'] || + !contact.get('profileKeyCredential') + ) { + await contact.getProfiles(); + } + + capabilities = contact.get('capabilities'); + if (!capabilities || !capabilities.gv2) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: membersV2 - member ${e164} is missing gv2 capability, skipping.` + ); + return null; + } + if (!capabilities || !capabilities['gv1-migration']) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: membersV2 - member ${e164} is missing gv1-migration capability, skipping.` + ); + return null; + } + if (!contact.get('profileKeyCredential')) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: membersV2 - no profileKeyCredential for ${e164}, skipping.` + ); + return null; + } + + const conversationId = contact.id; + + if (conversationId === ourConversationId) { + areWeMember = true; + } + + memberLookup[conversationId] = true; + + return { + conversationId, + role: MEMBER_ROLE_ENUM.ADMINISTRATOR, + joinedAtVersion: 0, + }; + }) + ) + ); + + const droppedGV2MemberIds: Array = []; + const pendingMembersV2: Array = compact( + (previousGroupV1Members || []).map(e164 => { + const contact = window.ConversationController.get(e164); + + if (!contact) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: pendingMembersV2 - missing local contact for ${e164}, skipping.` + ); + } + + const conversationId = contact.id; + // If we've already added this contact above, we'll skip here + if (memberLookup[conversationId]) { + return null; + } + + if (!contact.get('uuid')) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: pendingMembersV2 - missing uuid for ${e164}, skipping.` + ); + droppedGV2MemberIds.push(conversationId); + return null; + } + + const capabilities = contact.get('capabilities'); + if (!capabilities || !capabilities.gv2) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: pendingMembersV2 - member ${e164} is missing gv2 capability, skipping.` + ); + droppedGV2MemberIds.push(conversationId); + return null; + } + if (!capabilities || !capabilities['gv1-migration']) { + window.log.warn( + `initiateMigrationToGroupV2/${logId}: pendingMembersV2 - member ${e164} is missing gv1-migration capability, skipping.` + ); + droppedGV2MemberIds.push(conversationId); + return null; + } + + if (conversationId === ourConversationId) { + areWeInvited = true; + } + + return { + conversationId, + timestamp: now, + addedByUserId: ourConversationId, + }; + }) + ); + + if (!areWeMember) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: After members migration, we are not a member!` + ); + } + if (!areWeInvited) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: After members migration, we are invited!` + ); + } + + // Note: A few group elements don't need to change here: + // - avatar + // - name + // - expireTimer + + const newAttributes = { + ...conversation.attributes, + + // Core GroupV2 info + revision: 0, + groupId, + groupVersion: 2, + masterKey, + publicParams, + secretParams, + + // GroupV2 state + accessControl: { + attributes: ACCESS_ENUM.MEMBER, + members: ACCESS_ENUM.MEMBER, + }, + membersV2, + pendingMembersV2, + + // Capture previous GroupV1 data for future use + previousGroupV1Id, + previousGroupV1Members, + + // Clear storage ID, since we need to start over on the storage service + storageID: undefined, + + // Clear obsolete data + derivedGroupV2Id: undefined, + members: undefined, + }; + + const groupProto = await buildGroupProto({ attributes: newAttributes }); + + // Capture the CDK key provided by the server when we uploade + if (groupProto.avatar && newAttributes.avatar) { + newAttributes.avatar.url = groupProto.avatar; + } + + try { + await makeRequestWithTemporalRetry({ + logId: `createGroup/${logId}`, + publicParams, + secretParams, + request: (sender, options) => sender.createGroup(groupProto, options), + }); + } catch (error) { + window.log.error( + `initiateMigrationToGroupV2/${logId}: Error creating group:`, + error.stack + ); + + throw error; + } + + const groupChangeMessages: Array = []; + groupChangeMessages.push({ + ...generateBasicMessage(), + type: 'group-v1-migration', + invitedGV2Members: pendingMembersV2, + droppedGV2MemberIds, + }); + + await updateGroup({ + conversation, + updates: { + newAttributes, + groupChangeMessages, + members: [], + }, + }); + + if (window.storage.isGroupBlocked(previousGroupV1Id)) { + window.storage.addBlockedGroup(groupId); + } + + // Save these most recent updates to conversation + updateConversation(conversation.attributes); + }); + } catch (error) { + const logId = conversation.idForLogging(); + if (!conversation.isGroupV1()) { + throw error; + } + + const alreadyMigrated = await hasV1GroupBeenMigrated(conversation); + if (!alreadyMigrated) { + window.log.error( + `initiateMigrationToGroupV2/${logId}: Group has not already been migrated, re-throwing error` + ); + throw error; + } + + await respondToGroupV2Migration({ + conversation, + }); + + return; + } + + // We've migrated the group, now we need to let all other group members know about it + const logId = conversation.idForLogging(); + const timestamp = Date.now(); + const profileKey = conversation.get('profileKey'); + + await wrapWithSyncMessageSend({ + conversation, + logId: `sendMessageToGroup/${logId}`, + send: async sender => + // Minimal message to notify group members about migration + sender.sendMessageToGroup({ + groupV2: conversation.getGroupV2Info({ + includePendingMembers: true, + }), + timestamp, + profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined, + }), + timestamp, + }); +} + +async function wrapWithSyncMessageSend({ + conversation, + logId, + send, + timestamp, +}: { + conversation: ConversationModel; + logId: string; + send: (sender: MessageSender) => Promise; + timestamp: number; +}) { + const sender = window.textsecure.messaging; + if (!sender) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: textsecure.messaging is not available!` + ); + } + + let response: CallbackResultType | undefined; + try { + response = await send(sender); + } catch (error) { + if (conversation.processSendResponse(error)) { + response = error; + } + } + + if (!response) { + throw new Error( + `wrapWithSyncMessageSend/${logId}: message send didn't return result!!` + ); + } + + // Minimal implementation of sending same message to linked devices + const { dataMessage } = response; + if (!dataMessage) { + throw new Error( + `wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!` + ); + } + + const ourConversationId = window.ConversationController.getOurConversationId(); + if (!ourConversationId) { + throw new Error( + `wrapWithSyncMessageSend/${logId}: Cannot get our conversationId!` + ); + } + + const ourConversation = window.ConversationController.get(ourConversationId); + if (!ourConversation) { + throw new Error( + `wrapWithSyncMessageSend/${logId}: Cannot get our conversation!` + ); + } + + await sender.sendSyncMessage( + dataMessage, + timestamp, + ourConversation.get('e164'), + ourConversation.get('uuid'), + null, // expirationStartTimestamp + [], // sentTo + [], // unidentifiedDeliveries + undefined, // isUpdate + undefined // options + ); +} + +export async function waitThenRespondToGroupV2Migration( + options: MigratePropsType +): Promise { + // First wait to process all incoming messages on the websocket + await window.waitForEmptyEventQueue(); + + // Then wait to process all outstanding messages for this conversation + const { conversation } = options; + + await conversation.queueJob(async () => { + try { + // And finally try to migrate the group + await respondToGroupV2Migration(options); + } catch (error) { + window.log.error( + `waitThenRespondToGroupV2Migration/${conversation.idForLogging()}: respondToGroupV2Migration failure:`, + error && error.stack ? error.stack : error + ); + } + }); +} + +// This may be called from storage service, an out-of-band check, or an incoming message. +// If this is kicked off via an incoming message, we want to do the right thing and hit +// the log endpoint - the parameters beyond conversation are needed in that scenario. +export async function respondToGroupV2Migration({ + conversation, + groupChangeBase64, + newRevision, + receivedAt, + sentAt, +}: MigratePropsType): Promise { + // Ensure we have the credentials we need before attempting GroupsV2 operations + await maybeFetchNewCredentials(); + + const isGroupV1 = conversation.isGroupV1(); + const previousGroupV1Id = conversation.get('groupId'); + + if (!isGroupV1 || !previousGroupV1Id) { + throw new Error( + `respondToGroupV2Migration: Conversation is not GroupV1! ${conversation.idForLogging()}` + ); + } + + // If we were not previously a member, we won't migrate + const ourConversationId = window.ConversationController.getOurConversationId(); + const wereWePreviouslyAMember = + !conversation.get('left') && + ourConversationId && + conversation.hasMember(ourConversationId); + if (!ourConversationId || !wereWePreviouslyAMember) { + window.log.info( + `respondToGroupV2Migration: Not currently a member of ${conversation.idForLogging()}, returning early.` + ); + return; + } + + // Derive GroupV2 fields + const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id); + const masterKeyBuffer = await deriveMasterKeyFromGroupV1(groupV1IdBuffer); + const fields = deriveGroupFields(masterKeyBuffer); + + const groupId = arrayBufferToBase64(fields.id); + const logId = `groupv2(${groupId})`; + window.log.info( + `respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}` + ); + + const masterKey = arrayBufferToBase64(masterKeyBuffer); + const secretParams = arrayBufferToBase64(fields.secretParams); + const publicParams = arrayBufferToBase64(fields.publicParams); + + const previousGroupV1Members = conversation.get('members'); + const previousGroupV1MembersIds = conversation.getMemberIds(); + + // Skeleton of the new group state - not useful until we add the group's server state + const attributes = { + ...conversation.attributes, + + // Core GroupV2 info + revision: 0, + groupId, + groupVersion: 2, + masterKey, + publicParams, + secretParams, + + // Capture previous GroupV1 data for future use + previousGroupV1Id, + previousGroupV1Members, + + // Clear storage ID, since we need to start over on the storage service + storageID: undefined, + + // Clear obsolete data + derivedGroupV2Id: undefined, + members: undefined, + }; + + let firstGroupState: GroupClass | undefined | null; + + try { + const response: GroupLogResponseType = await makeRequestWithTemporalRetry({ + logId: `getGroupLog/${logId}`, + publicParams, + secretParams, + request: (sender, options) => sender.getGroupLog(0, options), + }); + + // Attempt to start with the first group state, only later processing future updates + firstGroupState = response?.changes?.groupChanges?.[0]?.groupState; + } catch (error) { + if (error.code === GROUP_ACCESS_DENIED_CODE) { + window.log.info( + `respondToGroupV2Migration/${logId}: Failed to access log endpoint; fetching full group state` + ); + firstGroupState = await makeRequestWithTemporalRetry({ + logId: `getGroup/${logId}`, + publicParams, + secretParams, + request: (sender, options) => sender.getGroup(options), + }); + } else { + throw error; + } + } + if (!firstGroupState) { + throw new Error( + `respondToGroupV2Migration/${logId}: Couldn't get a first group state!` + ); + } + + const groupState = decryptGroupState( + firstGroupState, + attributes.secretParams, + logId + ); + const newAttributes = await applyGroupState({ + group: attributes, + groupState, + }); + + // Assemble items to commemorate this event for the timeline.. + const combinedConversationIds: Array = [ + ...(newAttributes.membersV2 || []).map(item => item.conversationId), + ...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId), + ]; + const droppedGV2MemberIds: Array = difference( + previousGroupV1MembersIds, + combinedConversationIds + ).filter(id => id && id !== ourConversationId); + const invitedGV2Members = (newAttributes.pendingMembersV2 || []).filter( + item => item.conversationId !== ourConversationId + ); + + // Generate notifications into the timeline + const groupChangeMessages: Array = []; + groupChangeMessages.push({ + ...generateBasicMessage(), + type: 'group-v1-migration', + invitedGV2Members, + droppedGV2MemberIds, + }); + + const areWeInvited = (newAttributes.pendingMembersV2 || []).some( + item => item.conversationId === ourConversationId + ); + const areWeMember = (newAttributes.membersV2 || []).some( + item => item.conversationId === ourConversationId + ); + if (!areWeInvited && !areWeMember) { + // Add a message to the timeline saying the user was removed + groupChangeMessages.push({ + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + details: [ + { + type: 'member-remove' as const, + conversationId: ourConversationId, + }, + ], + }, + }); + } else if (areWeInvited && !areWeMember && ourConversationId) { + // Add a message to the timeline saying we were invited to the group + groupChangeMessages.push({ + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + details: [ + { + type: 'pending-add-one' as const, + conversationId: ourConversationId, + }, + ], + }, + }); + } + + // This buffer ensures that all migration-related messages are sorted above + // any initiating message. We need to do this because groupChangeMessages are + // already sorted via updates to sentAt inside of updateGroup(). + const SORT_BUFFER = 1000; + await updateGroup({ + conversation, + receivedAt, + sentAt: sentAt ? sentAt - SORT_BUFFER : undefined, + updates: { + newAttributes, + groupChangeMessages, + members: [], + }, + }); + + if (window.storage.isGroupBlocked(previousGroupV1Id)) { + window.storage.addBlockedGroup(groupId); + } + + // Save these most recent updates to conversation + updateConversation(conversation.attributes); + + // Finally, check for any changes to the group since its initial creation using normal + // group update codepaths. + await maybeUpdateGroup({ + conversation, + groupChangeBase64, + newRevision, + receivedAt, + sentAt, + }); +} + // Fetching and applying group changes type MaybeUpdatePropsType = { @@ -528,9 +1423,13 @@ async function updateGroup({ // Ensure that all generated messages are ordered properly. // Before the provided timestamp so update messages appear before the // initiating message, or after now(). - let syntheticTimestamp = receivedAt - ? receivedAt - (groupChangeMessages.length + 1) - : Date.now(); + const finalReceivedAt = receivedAt || Date.now(); + const finalSentAt = sentAt || Date.now(); + + // GroupV1 -> GroupV2 migration changes the groupId, and we need to update our id-based + // lookups if there's a change on that field. + const previousId = conversation.get('groupId'); + const idChanged = previousId && previousId !== newAttributes.groupId; conversation.set({ ...newAttributes, @@ -539,20 +1438,27 @@ async function updateGroup({ // Unknown Group in the left pane. active_at: isInitialDataFetch && newAttributes.name - ? syntheticTimestamp + ? finalReceivedAt : newAttributes.active_at, }); + if (idChanged) { + conversation.trigger('idUpdated', conversation, 'groupId', previousId); + } + // Save all synthetic messages describing group changes + let syntheticSentAt = finalSentAt - (groupChangeMessages.length + 1); const changeMessagesToSave = groupChangeMessages.map(changeMessage => { - // We do this to preserve the order of the timeline - syntheticTimestamp += 1; + // We do this to preserve the order of the timeline. We only update sentAt to ensure + // that we don't stomp on messages received around the same time as the message + // which initiated this group fetch and in-conversation messages. + syntheticSentAt += 1; return { ...changeMessage, conversationId: conversation.id, - received_at: syntheticTimestamp, - sent_at: sentAt, + received_at: finalReceivedAt, + sent_at: syntheticSentAt, }; }); @@ -579,10 +1485,6 @@ async function updateGroup({ // No need for convo.updateLastMessage(), 'newmessage' handler does that } -function idForLogging(group: ConversationAttributesType) { - return `groupv2(${group.groupId})`; -} - async function getGroupUpdates({ dropInitialJoinMessage, group, @@ -776,8 +1678,8 @@ function generateBasicMessage() { function generateLeftGroupChanges( group: ConversationAttributesType ): UpdatesResultType { - const idLog = idForLogging(group); - window.log.info(`generateLeftGroupChanges/${idLog}: Starting...`); + const logId = idForLogging(group); + window.log.info(`generateLeftGroupChanges/${logId}: Starting...`); const ourConversationId = window.ConversationController.getOurConversationId(); if (!ourConversationId) { throw new Error( @@ -903,7 +1805,7 @@ async function integrateGroupChanges({ newRevision: number; changes: Array; }): Promise { - const idLog = idForLogging(group); + const logId = idForLogging(group); let attributes = group; const finalMessages: Array> = []; const finalMembers: Array> = []; @@ -947,7 +1849,7 @@ async function integrateGroupChanges({ finalMembers.push(members); } catch (error) { window.log.error( - `integrateGroupChanges/${idLog}: Failed to apply change log, continuing to apply remaining change logs.`, + `integrateGroupChanges/${logId}: Failed to apply change log, continuing to apply remaining change logs.`, error && error.stack ? error.stack : error ); } @@ -1485,9 +2387,7 @@ async function applyGroupChange({ const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; const version = actions.version || 0; - const result: ConversationAttributesType = { - ...group, - }; + const result = { ...group }; const newProfileKeys: Array = []; const members: Dictionary = fromPairs( @@ -1886,9 +2786,7 @@ async function applyGroupState({ const logId = idForLogging(group); const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const version = groupState.version || 0; - const result: ConversationAttributesType = { - ...group, - }; + const result = { ...group }; // version result.revision = version; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index e961dda09..8ecac3fb6 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -18,6 +18,7 @@ import { UserMessage } from './types/Message'; import { MessageModel } from './models/messages'; import { ConversationModel } from './models/conversations'; import { ProfileNameChangeType } from './util/getStringForProfileChange'; +import { CapabilitiesType } from './textsecure/WebAPI'; interface ModelAttributesInterface { [key: string]: any; @@ -56,6 +57,7 @@ export type MessageAttributesType = { deletedForEveryoneTimestamp?: number; delivered: number; delivered_to: Array; + droppedGV2MemberIds?: Array; errors: Array | null; expirationStartTimestamp: number | null; expireTimer: number; @@ -72,6 +74,7 @@ export type MessageAttributesType = { isErased: boolean; isTapToViewInvalid: boolean; isViewOnce: boolean; + invitedGV2Members?: Array; key_changed: string; local: boolean; logger: unknown; @@ -143,7 +146,7 @@ export type ConversationAttributesTypeType = 'private' | 'group'; export type ConversationAttributesType = { accessKey: string | null; addedBy?: string; - capabilities: { uuid: string }; + capabilities?: CapabilitiesType; color?: string; discoveredUnregisteredAt: number; draftAttachments: Array; @@ -202,6 +205,7 @@ export type ConversationAttributesType = { // GroupV1 only members?: Array; + derivedGroupV2Id?: string; // GroupV2 core info masterKey?: string; @@ -222,6 +226,8 @@ export type ConversationAttributesType = { expireTimer?: number; membersV2?: Array; pendingMembersV2?: Array; + previousGroupV1Id?: string; + previousGroupV1Members?: Array; }; export type GroupV2MemberType = { @@ -230,7 +236,7 @@ export type GroupV2MemberType = { joinedAtVersion: number; }; export type GroupV2PendingMemberType = { - addedByUserId: string; + addedByUserId?: string; conversationId: string; timestamp: number; }; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 0cc02cbab..79a9039ec 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -278,10 +278,21 @@ export class ConversationModel extends window.Backbone.Model< const groupVersion = this.get('groupVersion') || 0; +<<<<<<< HEAD return ( groupVersion === 2 && base64ToArrayBuffer(groupId).byteLength === window.Signal.Groups.ID_LENGTH ); +======= + try { + return ( + groupVersion === 2 && base64ToArrayBuffer(groupId).byteLength === 32 + ); + } catch (error) { + window.log.error('isGroupV2: Failed to process groupId in base64!'); + return false; + } +>>>>>>> Support for GV1 -> GV2 migration } isMemberPending(conversationId: string): boolean { @@ -508,7 +519,6 @@ export class ConversationModel extends window.Backbone.Model< const groupChange = await window.Signal.Groups.uploadGroupChange({ actions, group: this.attributes, - serverPublicParamsBase64: window.getServerPublicParams(), }); const groupChangeBuffer = groupChange.toArrayBuffer(); @@ -830,6 +840,21 @@ export class ConversationModel extends window.Backbone.Model< return this.isPrivate() || this.isGroupV1() || this.isGroupV2(); } + async maybeMigrateV1Group(): Promise { + if (!this.isGroupV1()) { + return; + } + + const isMigrated = await window.Signal.Groups.hasV1GroupBeenMigrated(this); + if (!isMigrated) { + return; + } + + await window.Signal.Groups.waitThenRespondToGroupV2Migration({ + conversation: this, + }); + } + maybeRepairGroupV2(data: { masterKey: string; secretParams: string; @@ -1509,21 +1534,33 @@ export class ConversationModel extends window.Backbone.Model< ) ); } catch (result) { - if (result instanceof Error) { - throw result; - } else if (result && result.errors) { - // We filter out unregistered user errors, because we ignore those in groups - const wasThereARealError = window._.some( - result.errors, - error => error.name !== 'UnregisteredUserError' - ); - if (wasThereARealError) { - throw result; - } - } + this.processSendResponse(result); } } + // We only want to throw if there's a 'real' error contained with this information + // coming back from our low-level send infrastructure. + processSendResponse( + result: Error | CallbackResultType + ): result is CallbackResultType { + if (result instanceof Error) { + throw result; + } else if (result && result.errors) { + // We filter out unregistered user errors, because we ignore those in groups + const wasThereARealError = window._.some( + result.errors, + error => error.name !== 'UnregisteredUserError' + ); + if (wasThereARealError) { + throw result; + } + + return true; + } + + return true; + } + onMessageError(): void { this.updateVerified(); } @@ -2916,10 +2953,6 @@ export class ConversationModel extends window.Backbone.Model< }; } - getUuidCapable(): boolean { - return Boolean(window._.property('uuid')(this.get('capabilities'))); - } - getSendMetadata( options: { syncMessage?: string; disableMeCheck?: boolean } = {} ): WhatIsThis | null { @@ -2946,7 +2979,6 @@ export class ConversationModel extends window.Backbone.Model< const accessKey = this.get('accessKey'); const sealedSender = this.get('sealedSender'); - const uuidCapable = this.getUuidCapable(); // We never send sync messages as sealed sender if (syncMessage && this.isMe()) { @@ -2960,9 +2992,6 @@ export class ConversationModel extends window.Backbone.Model< if (sealedSender === SEALED_SENDER.UNKNOWN) { const info = { accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)), - // Indicates that a client is capable of receiving uuid-only messages. - // Not used yet. - uuidCapable, }; return { ...(e164 ? { [e164]: info } : {}), @@ -2979,9 +3008,6 @@ export class ConversationModel extends window.Backbone.Model< accessKey && sealedSender === SEALED_SENDER.ENABLED ? accessKey : arrayBufferToBase64(getRandomBytes(16)), - // Indicates that a client is capable of receiving uuid-only messages. - // Not used yet. - uuidCapable, }; return { @@ -4172,19 +4198,16 @@ export class ConversationModel extends window.Backbone.Model< }); } - notifyTyping( - options: { - isTyping: boolean; - senderId: string; - isMe: boolean; - senderDevice: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } = ({} as unknown) as any - ): void { - const { isTyping, senderId, isMe, senderDevice } = options; + notifyTyping(options: { + isTyping: boolean; + senderId: string; + fromMe: boolean; + senderDevice: string; + }): void { + const { isTyping, senderId, fromMe, senderDevice } = options; // We don't do anything with typing messages from our other devices - if (isMe) { + if (fromMe) { return; } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 9782f2053..f6f41ddf7 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -6,6 +6,7 @@ import { MessageAttributesType, CustomError, } from '../model-types.d'; +import { DataMessageClass } from '../textsecure.d'; import { ConversationModel } from './conversations'; import { LastMessageStatus, @@ -22,6 +23,7 @@ import { } from '../components/conversation/TimerNotification'; import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification'; import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification'; +import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration'; import { PropsData as GroupNotificationProps, ChangeType, @@ -195,6 +197,7 @@ export class MessageModel extends window.Backbone.Model { !this.isExpirationTimerUpdate() && !this.isGroupUpdate() && !this.isGroupV2Change() && + !this.isGroupV1Migration() && !this.isKeyChange() && !this.isMessageHistoryUnsynced() && !this.isProfileChange() && @@ -217,6 +220,12 @@ export class MessageModel extends window.Backbone.Model { data: this.getPropsForGroupV2Change(), }; } + if (this.isGroupV1Migration()) { + return { + type: 'groupV1Migration', + data: this.getPropsForGroupV1Migration(), + }; + } if (this.isMessageHistoryUnsynced()) { return { type: 'linkNotification', @@ -428,6 +437,10 @@ export class MessageModel extends window.Backbone.Model { return Boolean(this.get('groupV2Change')); } + isGroupV1Migration(): boolean { + return this.get('type') === 'group-v1-migration'; + } + isExpirationTimerUpdate(): boolean { const flag = window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; @@ -501,6 +514,23 @@ export class MessageModel extends window.Backbone.Model { }; } + getPropsForGroupV1Migration(): GroupV1MigrationPropsType { + const invitedGV2Members = this.get('invitedGV2Members') || []; + const droppedGV2MemberIds = this.get('droppedGV2MemberIds') || []; + + const invitedMembers = invitedGV2Members.map(item => + this.findAndFormatContact(item.conversationId) + ); + const droppedMembers = droppedGV2MemberIds.map(conversationId => + this.findAndFormatContact(conversationId) + ); + + return { + droppedMembers, + invitedMembers, + }; + } + getPropsForTimerNotification(): TimerNotificationProps | undefined { const timerUpdate = this.get('expirationTimerUpdate'); if (!timerUpdate) { @@ -1082,9 +1112,9 @@ export class MessageModel extends window.Backbone.Model { }; } - if (this.get('deletedForEveryone')) { + if (this.isGroupV1Migration()) { return { - text: window.i18n('message--deletedForEveryone'), + text: window.i18n('GroupV1--Migration--was-upgraded'), }; } @@ -2771,7 +2801,7 @@ export class MessageModel extends window.Backbone.Model { } handleDataMessage( - initialMessage: typeof window.WhatIsThis, + initialMessage: DataMessageClass, confirm: () => void, options: { data?: typeof window.WhatIsThis } = {} ): WhatIsThis { @@ -2863,40 +2893,58 @@ export class MessageModel extends window.Backbone.Model { } } - const existingRevision = conversation.get('revision'); - const isGroupV2 = Boolean(initialMessage.groupV2); - const isV2GroupUpdate = - initialMessage.groupV2 && - (!existingRevision || - initialMessage.groupV2.revision > existingRevision); - // GroupV2 - if (isGroupV2) { - conversation.maybeRepairGroupV2( - _.pick(initialMessage.groupV2, [ - 'masterKey', - 'secretParams', - 'publicParams', - ]) - ); - } - if (isV2GroupUpdate) { - const { revision, groupChange } = initialMessage.groupV2; - try { - await window.Signal.Groups.maybeUpdateGroup({ + if (initialMessage.groupV2) { + if (conversation.isGroupV1()) { + // If we received a GroupV2 message in a GroupV1 group, we migrate! + + const { revision, groupChange } = initialMessage.groupV2; + await window.Signal.Groups.respondToGroupV2Migration({ conversation, groupChangeBase64: groupChange, newRevision: revision, receivedAt: message.get('received_at'), sentAt: message.get('sent_at'), }); - } catch (error) { - const errorText = error && error.stack ? error.stack : error; - window.log.error( - `handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` - ); - throw error; + } else if ( + initialMessage.groupV2.masterKey && + initialMessage.groupV2.secretParams && + initialMessage.groupV2.publicParams + ) { + // Repair core GroupV2 data if needed + await conversation.maybeRepairGroupV2({ + masterKey: initialMessage.groupV2.masterKey, + secretParams: initialMessage.groupV2.secretParams, + publicParams: initialMessage.groupV2.publicParams, + }); + + // Standard GroupV2 modification codepath + const existingRevision = conversation.get('revision'); + const isV2GroupUpdate = + initialMessage.groupV2 && + _.isNumber(initialMessage.groupV2.revision) && + (!existingRevision || + initialMessage.groupV2.revision > existingRevision); + + if (isV2GroupUpdate && initialMessage.groupV2) { + const { revision, groupChange } = initialMessage.groupV2; + try { + await window.Signal.Groups.maybeUpdateGroup({ + conversation, + groupChangeBase64: groupChange, + newRevision: revision, + receivedAt: message.get('received_at'), + sentAt: message.get('sent_at'), + }); + } catch (error) { + const errorText = error && error.stack ? error.stack : error; + window.log.error( + `handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` + ); + throw error; + } + } } } @@ -2907,6 +2955,7 @@ export class MessageModel extends window.Backbone.Model { e164: source, uuid: sourceUuid, })!; + const isGroupV2 = Boolean(initialMessage.groupV2); const isV1GroupUpdate = initialMessage.group && initialMessage.group.type !== @@ -2949,6 +2998,16 @@ export class MessageModel extends window.Backbone.Model { return; } + // Because GroupV1 messages can now be multiplexed into GroupV2 conversations, we + // drop GroupV1 updates in GroupV2 groups. + if (isV1GroupUpdate && conversation.isGroupV2()) { + window.log.warn( + `Received GroupV1 update in GroupV2 conversation ${conversation.idForLogging()}. Dropping.` + ); + confirm(); + return; + } + // Send delivery receipts, but only for incoming sealed sender messages // and not for messages from unaccepted conversations if ( diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 4f72acbbe..9ad1522fb 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -16,7 +16,11 @@ import { GroupV2RecordClass, PinnedConversationClass, } from '../textsecure.d'; -import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups'; +import { + deriveGroupFields, + waitThenMaybeUpdateGroup, + waitThenRespondToGroupV2Migration, +} from '../groups'; import { ConversationModel } from '../models/conversations'; import { ConversationAttributesTypeType } from '../model-types.d'; @@ -414,6 +418,53 @@ export async function mergeGroupV1Record( return hasPendingChanges; } +async function getGroupV2Conversation( + masterKeyBuffer: ArrayBuffer +): Promise { + const groupFields = deriveGroupFields(masterKeyBuffer); + + const groupId = arrayBufferToBase64(groupFields.id); + const masterKey = arrayBufferToBase64(masterKeyBuffer); + const secretParams = arrayBufferToBase64(groupFields.secretParams); + const publicParams = arrayBufferToBase64(groupFields.publicParams); + + // First we check for an existing GroupV2 group + const groupV2 = window.ConversationController.get(groupId); + if (groupV2) { + await groupV2.maybeRepairGroupV2({ + masterKey, + secretParams, + publicParams, + }); + + return groupV2; + } + + // Then check for V1 group with matching derived GV2 id + const groupV1 = window.ConversationController.getByDerivedGroupV2Id(groupId); + if (groupV1) { + return groupV1; + } + + const conversationId = window.ConversationController.ensureGroup(groupId, { + // Note: We don't set active_at, because we don't want the group to show until + // we have information about it beyond these initial details. + // see maybeUpdateGroup(). + groupVersion: 2, + masterKey, + secretParams, + publicParams, + }); + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error( + `getGroupV2Conversation: Failed to create conversation for groupv2(${groupId})` + ); + } + + return conversation; +} + export async function mergeGroupV2Record( storageID: string, groupV2Record: GroupV2RecordClass @@ -423,36 +474,7 @@ export async function mergeGroupV2Record( } const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer(); - const groupFields = deriveGroupFields(masterKeyBuffer); - - const groupId = arrayBufferToBase64(groupFields.id); - const masterKey = arrayBufferToBase64(masterKeyBuffer); - const secretParams = arrayBufferToBase64(groupFields.secretParams); - const publicParams = arrayBufferToBase64(groupFields.publicParams); - - const now = Date.now(); - const conversationId = window.ConversationController.ensureGroup(groupId, { - // Note: We don't set active_at, because we don't want the group to show until - // we have information about it beyond these initial details. - // see maybeUpdateGroup(). - timestamp: now, - // Basic GroupV2 data - groupVersion: 2, - masterKey, - secretParams, - publicParams, - }); - const conversation = window.ConversationController.get(conversationId); - - if (!conversation) { - throw new Error(`No conversation for groupv2(${groupId})`); - } - - conversation.maybeRepairGroupV2({ - masterKey, - secretParams, - publicParams, - }); + const conversation = await getGroupV2Conversation(masterKeyBuffer); conversation.set({ isArchived: Boolean(groupV2Record.archived), @@ -476,10 +498,22 @@ export async function mergeGroupV2Record( const isFirstSync = !window.storage.get('storageFetchComplete'); const dropInitialJoinMessage = isFirstSync; - // We don't need to update GroupV2 groups all the time. We fetch group state the first - // time we hear about these groups, from then on we rely on incoming messages or - // the user opening that conversation. - if (isGroupNewToUs) { + if (conversation.isGroupV1()) { + // If we found a GroupV1 conversation from this incoming GroupV2 record, we need to + // migrate it! + + // We don't await this because this could take a very long time, waiting for queues to + // empty, etc. + waitThenRespondToGroupV2Migration({ + conversation, + }); + } else if (isGroupNewToUs) { + // We don't need to update GroupV2 groups all the time. We fetch group state the first + // time we hear about these groups, from then on we rely on incoming messages or + // the user opening that conversation. + + // We don't await this because this could take a very long time, waiting for queues to + // empty, etc. waitThenMaybeUpdateGroup({ conversation, dropInitialJoinMessage, diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 4ddba7012..9d5bb1bfb 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2784,7 +2784,7 @@ async function getLastConversationActivity( const row = await db.get( `SELECT * FROM messages WHERE conversationId = $conversationId AND - (type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced', 'keychange')) AND + (type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced', 'keychange', 'group-v1-migration')) AND (json_extract(json, '$.expirationTimerUpdate.fromSync') IS NULL OR json_extract(json, '$.expirationTimerUpdate.fromSync') != 1) ORDER BY received_at DESC LIMIT 1;`, @@ -2806,7 +2806,7 @@ async function getLastConversationPreview( const row = await db.get( `SELECT * FROM messages WHERE conversationId = $conversationId AND - (type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced')) + (type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced', 'group-v1-migration')) ORDER BY received_at DESC LIMIT 1;`, { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index eb89b27dc..562f493b0 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -65,7 +65,7 @@ export type ConversationType = { text: string; deletedForEveryone?: boolean; }; - markedUnread: boolean; + markedUnread?: boolean; phoneNumber?: string; membersCount?: number; expireTimer?: number; @@ -73,7 +73,7 @@ export type ConversationType = { muteExpiresAt?: number; type: ConversationTypeType; isMe?: boolean; - lastUpdated: number; + lastUpdated?: number; title: string; unreadCount?: number; isSelected?: boolean; diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index b42b9ae75..802776007 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -16,6 +16,7 @@ import SendMessage, { SendOptionsType } from './textsecure/SendMessage'; import { WebAPIType } from './textsecure/WebAPI'; import utils from './textsecure/Helpers'; import { CallingMessage as CallingMessageClass } from 'ringrtc'; +import { WhatIsThis } from './window.d'; type AttachmentType = any; @@ -256,6 +257,8 @@ export declare class MemberClass { profileKey?: ProtoBinaryType; presentation?: ProtoBinaryType; joinedAtVersion?: number; + + // Note: only role and presentation are required when creating a group } type MemberRoleEnum = number; @@ -719,6 +722,9 @@ export declare class GroupContextClass { name?: string | null; membersE164?: Array; avatar?: AttachmentPointerClass | null; + + // Note: these additional properties are added in the course of processing + derivedGroupV2Id?: string; } export declare class GroupContextV2Class { diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 61f7d914c..ae25aa495 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -23,6 +23,7 @@ import WebSocketResource, { IncomingWebSocketRequest, } from './WebsocketResources'; import Crypto from './Crypto'; +import { deriveMasterKeyFromGroupV1 } from '../Crypto'; import { ContactBuffer, GroupBuffer } from './ContactsParser'; import { IncomingIdentityKeyError } from './Errors'; @@ -43,6 +44,8 @@ import { WebSocket } from './WebSocket'; import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups'; +const GROUPV1_ID_LENGTH = 16; +const GROUPV2_ID_LENGTH = 32; const RETRY_TIMEOUT = 2 * 60 * 1000; declare global { @@ -58,6 +61,7 @@ declare global { eventType?: string | number; groupDetails?: any; groupId?: string; + groupV2Id?: string; messageRequestResponseType?: number | null; proto?: any; read?: any; @@ -273,6 +277,7 @@ class MessageReceiverInner extends EventTarget { delete this.socket.onclose; delete this.socket.onerror; delete this.socket.onopen; + this.socket = undefined; } @@ -1201,7 +1206,13 @@ class MessageReceiverInner extends EventTarget { ); } - this.deriveGroupsV2Data(msg); + if (this.isInvalidGroupData(msg, envelope)) { + this.removeFromCache(envelope); + return undefined; + } + + this.deriveGroupV1Data(msg); + this.deriveGroupV2Data(msg); if ( msg.flags && @@ -1377,7 +1388,7 @@ class MessageReceiverInner extends EventTarget { return Promise.all(results); } - handleTypingMessage( + async handleTypingMessage( envelope: EnvelopeClass, typingMessage: TypingMessageClass ) { @@ -1403,25 +1414,29 @@ class MessageReceiverInner extends EventTarget { ev.senderUuid = envelope.sourceUuid; ev.senderDevice = envelope.sourceDevice; - const groupIdBuffer = groupId ? groupId.toArrayBuffer() : null; - ev.typing = { typingMessage, timestamp: timestamp ? timestamp.toNumber() : Date.now(), - groupId: - groupIdBuffer && groupIdBuffer.byteLength <= 16 - ? groupId.toString('binary') - : null, - groupV2Id: - groupIdBuffer && groupIdBuffer.byteLength > 16 - ? groupId.toString('base64') - : null, started: action === window.textsecure.protobuf.TypingMessage.Action.STARTED, stopped: action === window.textsecure.protobuf.TypingMessage.Action.STOPPED, }; + const groupIdBuffer = groupId ? groupId.toArrayBuffer() : null; + + if (groupIdBuffer && groupIdBuffer.byteLength > 0) { + if (groupIdBuffer.byteLength === GROUPV1_ID_LENGTH) { + ev.typing.groupId = groupId.toString('binary'); + ev.typing.groupV2Id = await this.deriveGroupV2FromV1(groupIdBuffer); + } else if (groupIdBuffer.byteLength === GROUPV2_ID_LENGTH) { + ev.typing.groupV2Id = groupId.toString('base64'); + } else { + window.log.error('handleTypingMessage: Received invalid groupId value'); + this.removeFromCache(envelope); + } + } + return this.dispatchEvent(ev); } @@ -1430,7 +1445,76 @@ class MessageReceiverInner extends EventTarget { this.removeFromCache(envelope); } - deriveGroupsV2Data(message: DataMessageClass) { + isInvalidGroupData( + message: DataMessageClass, + envelope: EnvelopeClass + ): boolean { + const { group, groupV2 } = message; + + if (group) { + const id = group.id.toArrayBuffer(); + const isInvalid = id.byteLength !== GROUPV1_ID_LENGTH; + + if (isInvalid) { + window.log.info( + 'isInvalidGroupData: invalid GroupV1 message from', + this.getEnvelopeId(envelope) + ); + } + + return isInvalid; + } + + if (groupV2) { + const masterKey = groupV2.masterKey.toArrayBuffer(); + const isInvalid = masterKey.byteLength !== MASTER_KEY_LENGTH; + + if (isInvalid) { + window.log.info( + 'isInvalidGroupData: invalid GroupV2 message from', + this.getEnvelopeId(envelope) + ); + } + return isInvalid; + } + + return false; + } + + async deriveGroupV2FromV1(groupId: ArrayBuffer): Promise { + if (groupId.byteLength !== GROUPV1_ID_LENGTH) { + throw new Error( + `deriveGroupV2FromV1: had id with wrong byteLength: ${groupId.byteLength}` + ); + } + const masterKey = await deriveMasterKeyFromGroupV1(groupId); + const data = deriveGroupFields(masterKey); + + const toBase64 = MessageReceiverInner.arrayBufferToStringBase64; + return toBase64(data.id); + } + + async deriveGroupV1Data(message: DataMessageClass) { + const { group } = message; + + if (!group) { + return; + } + + if (!group.id) { + throw new Error('deriveGroupV1Data: had falsey id'); + } + + const id = group.id.toArrayBuffer(); + if (id.byteLength !== GROUPV1_ID_LENGTH) { + throw new Error( + `deriveGroupV1Data: had id with wrong byteLength: ${id.byteLength}` + ); + } + group.derivedGroupV2Id = await this.deriveGroupV2FromV1(id); + } + + deriveGroupV2Data(message: DataMessageClass) { const { groupV2 } = message; if (!groupV2) { @@ -1438,10 +1522,10 @@ class MessageReceiverInner extends EventTarget { } if (!isNumber(groupV2.revision)) { - throw new Error('deriveGroupsV2Data: revision was not a number'); + throw new Error('deriveGroupV2Data: revision was not a number'); } if (!groupV2.masterKey) { - throw new Error('deriveGroupsV2Data: had falsey masterKey'); + throw new Error('deriveGroupV2Data: had falsey masterKey'); } const toBase64 = MessageReceiverInner.arrayBufferToStringBase64; @@ -1449,7 +1533,7 @@ class MessageReceiverInner extends EventTarget { const length = masterKey.byteLength; if (length !== MASTER_KEY_LENGTH) { throw new Error( - `deriveGroupsV2Data: masterKey had length ${length}, expected ${MASTER_KEY_LENGTH}` + `deriveGroupV2Data: masterKey had length ${length}, expected ${MASTER_KEY_LENGTH}` ); } @@ -1522,7 +1606,13 @@ class MessageReceiverInner extends EventTarget { ); } - this.deriveGroupsV2Data(sentMessage.message); + if (this.isInvalidGroupData(sentMessage.message, envelope)) { + this.removeFromCache(envelope); + return undefined; + } + + this.deriveGroupV1Data(sentMessage.message); + this.deriveGroupV2Data(sentMessage.message); window.log.info( 'sent message to', @@ -1630,14 +1720,32 @@ class MessageReceiverInner extends EventTarget { ev.confirm = this.removeFromCache.bind(this, envelope); ev.threadE164 = sync.threadE164; ev.threadUuid = sync.threadUuid; - ev.groupId = sync.groupId ? sync.groupId.toString('binary') : null; ev.messageRequestResponseType = sync.type; + const idBuffer: ArrayBuffer = sync.groupId + ? sync.groupId.toArrayBuffer() + : null; + + if (idBuffer && idBuffer.byteLength > 0) { + if (idBuffer.byteLength === GROUPV1_ID_LENGTH) { + ev.groupId = sync.groupId.toString('binary'); + ev.groupV2Id = await this.deriveGroupV2FromV1(idBuffer); + } else if (idBuffer.byteLength === GROUPV2_ID_LENGTH) { + ev.groupV2Id = sync.groupId.toString('base64'); + } else { + this.removeFromCache(envelope); + window.log.error('Received message request with invalid groupId'); + return undefined; + } + } + window.normalizeUuids( ev, ['threadUuid'], 'MessageReceiver::handleMessageRequestResponse' ); + + return this.dispatchAndWait(ev); } async handleFetchLatest( diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index adc81e7bf..983386ae0 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1705,6 +1705,20 @@ export default class MessageSender { return this.sendMessage(attrs, options); } + async createGroup( + group: GroupClass, + options: GroupCredentialsType + ): Promise { + return this.server.createGroup(group, options); + } + + async uploadGroupAvatar( + avatar: ArrayBuffer, + options: GroupCredentialsType + ): Promise { + return this.server.uploadGroupAvatar(avatar, options); + } + async getGroup(options: GroupCredentialsType): Promise { return this.server.getGroup(options); } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 930c61519..a67206baa 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -620,7 +620,7 @@ const URL_CALLS = { devices: 'v1/devices', directoryAuth: 'v1/directory/auth', discovery: 'v1/discovery', - getGroupAvatarUpload: '/v1/groups/avatar/form', + getGroupAvatarUpload: 'v1/groups/avatar/form', getGroupCredentials: 'v1/certificate/group', getIceServers: 'v1/accounts/turn', getStickerPackUpload: 'v1/sticker/pack/form', @@ -689,6 +689,15 @@ export type WebAPIConnectType = { connect: (options: ConnectParametersType) => WebAPIType; }; +export type CapabilitiesType = { + gv2: boolean; + 'gv1-migration': boolean; +}; +export type CapabilitiesUploadType = { + 'gv2-3': boolean; + 'gv1-migration': boolean; +}; + type StickerPackManifestType = any; export type GroupCredentialType = { @@ -796,7 +805,7 @@ export type WebAPIType = { ) => Promise; modifyStorageRecords: MessageSender['modifyStorageRecords']; putAttachment: (encryptedBin: ArrayBuffer) => Promise; - registerCapabilities: (capabilities: Dictionary) => Promise; + registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise; putStickers: ( encryptedManifest: ArrayBuffer, encryptedStickers: Array, @@ -1154,7 +1163,7 @@ export function initialize({ }); } - async function registerCapabilities(capabilities: Dictionary) { + async function registerCapabilities(capabilities: CapabilitiesUploadType) { return _ajax({ call: 'registerCapabilities', httpType: 'PUT', @@ -1280,11 +1289,14 @@ export function initialize({ deviceName?: string | null, options: { accessKey?: ArrayBuffer } = {} ) { + const capabilities: CapabilitiesUploadType = { + 'gv2-3': true, + 'gv1-migration': true, + }; + const { accessKey } = options; const jsonData: any = { - capabilities: { - 'gv2-3': true, - }, + capabilities, fetchesMessages: true, name: deviceName || undefined, registrationId, @@ -2010,9 +2022,10 @@ export function initialize({ await _ajax({ basicAuth, call: 'groups', - httpType: 'PUT', + contentType: 'application/x-protobuf', data, host: storageUrl, + httpType: 'PUT', }); } @@ -2027,10 +2040,10 @@ export function initialize({ const response: ArrayBuffer = await _ajax({ basicAuth, call: 'groups', - httpType: 'GET', contentType: 'application/x-protobuf', - responseType: 'arraybuffer', host: storageUrl, + httpType: 'GET', + responseType: 'arraybuffer', }); return window.textsecure.protobuf.Group.decode(response); @@ -2049,11 +2062,11 @@ export function initialize({ const response: ArrayBuffer = await _ajax({ basicAuth, call: 'groups', - httpType: 'PATCH', - data, contentType: 'application/x-protobuf', - responseType: 'arraybuffer', + data, host: storageUrl, + httpType: 'PATCH', + responseType: 'arraybuffer', }); return window.textsecure.protobuf.GroupChange.decode(response); @@ -2071,11 +2084,11 @@ export function initialize({ const withDetails: ArrayBufferWithDetailsType = await _ajax({ basicAuth, call: 'groupLog', - urlParameters: `/${startVersion}`, - httpType: 'GET', contentType: 'application/x-protobuf', - responseType: 'arraybufferwithdetails', host: storageUrl, + httpType: 'GET', + responseType: 'arraybufferwithdetails', + urlParameters: `/${startVersion}`, }); const { data, response } = withDetails; const changes = window.textsecure.protobuf.GroupChanges.decode(data); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 2c21ed804..9b5dcbf16 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -15164,7 +15164,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.js", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);", - "lineNumber": 1260, + "lineNumber": 1263, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" }, @@ -15172,8 +15172,8 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.ts", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", - "lineNumber": 2158, + "lineNumber": 2171, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" } -] +] \ No newline at end of file diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index aa82cbaa0..4f0f2b6d9 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -369,6 +369,9 @@ Whisper.ConversationView = Whisper.View.extend({ this.model.fetchLatestGroupV2Data.bind(this.model), FIVE_MINUTES ); + this.model.throttledMaybeMigrateV1Group = + this.model.throttledMaybeMigrateV1Group || + _.throttle(this.model.maybeMigrateV1Group.bind(this.model), FIVE_MINUTES); this.debouncedMaybeGrabLinkPreview = _.debounce( this.maybeGrabLinkPreview.bind(this), @@ -2035,6 +2038,7 @@ Whisper.ConversationView = Whisper.View.extend({ } this.model.throttledFetchLatestGroupV2Data(); + this.model.throttledMaybeMigrateV1Group(); const statusPromise = this.model.throttledGetProfiles(); // eslint-disable-next-line more/no-then diff --git a/ts/window.d.ts b/ts/window.d.ts index 33d2f857b..1f48aa6fd 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -72,7 +72,7 @@ export { Long } from 'long'; type TaskResultType = any; -type WhatIsThis = any; +export type WhatIsThis = any; declare global { interface Window {