2021-02-19 18:40:41 +00:00
// Copyright 2020-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2020-09-09 02:25:05 +00:00
import {
compact ,
Dictionary ,
2020-11-20 17:30:45 +00:00
difference ,
2020-09-09 02:25:05 +00:00
flatten ,
fromPairs ,
isNumber ,
values ,
} from 'lodash' ;
2020-09-11 19:37:01 +00:00
import { ClientZkGroupCipher } from 'zkgroup' ;
import { v4 as getGuid } from 'uuid' ;
2021-04-09 18:20:37 +00:00
import LRU from 'lru-cache' ;
2020-09-09 02:25:05 +00:00
import {
getCredentialsForToday ,
GROUP_CREDENTIALS_KEY ,
maybeFetchNewCredentials ,
} from './services/groupCredentialFetcher' ;
2021-03-03 20:09:58 +00:00
import { isStorageWriteFeatureEnabled } from './storage/isFeatureEnabled' ;
2020-11-20 17:30:45 +00:00
import dataInterface from './sql/Client' ;
2021-01-29 22:16:48 +00:00
import { toWebSafeBase64 , fromWebSafeBase64 } from './util/webSafeBase64' ;
2021-06-22 14:46:42 +00:00
import { assert , strictAssert } from './util/assert' ;
2021-05-07 20:07:24 +00:00
import { isMoreRecentThan } from './util/timestamp' ;
2021-06-22 14:46:42 +00:00
import { normalizeUuid } from './util/normalizeUuid' ;
import { dropNull } from './util/dropNull' ;
2020-09-09 02:25:05 +00:00
import {
ConversationAttributesType ,
GroupV2MemberType ,
2020-12-18 19:27:43 +00:00
GroupV2PendingAdminApprovalType ,
2020-09-09 02:25:05 +00:00
GroupV2PendingMemberType ,
MessageAttributesType ,
} from './model-types.d' ;
import {
2020-10-06 17:06:34 +00:00
createProfileKeyCredentialPresentation ,
2020-09-09 02:25:05 +00:00
decryptGroupBlob ,
decryptProfileKey ,
decryptProfileKeyCredentialPresentation ,
decryptUuid ,
deriveGroupID ,
deriveGroupPublicParams ,
deriveGroupSecretParams ,
encryptGroupBlob ,
2020-10-06 17:06:34 +00:00
encryptUuid ,
2020-09-09 02:25:05 +00:00
getAuthCredentialPresentation ,
getClientZkAuthOperations ,
getClientZkGroupCipher ,
2020-10-06 17:06:34 +00:00
getClientZkProfileOperations ,
2020-09-09 02:25:05 +00:00
} from './util/zkgroup' ;
import {
computeHash ,
2020-11-20 17:30:45 +00:00
deriveMasterKeyFromGroupV1 ,
fromEncodedBinaryToArrayBuffer ,
2021-01-29 21:19:24 +00:00
getRandomBytes ,
2021-06-22 14:46:42 +00:00
typedArrayToArrayBuffer ,
2020-09-09 02:25:05 +00:00
} from './Crypto' ;
2020-11-20 17:30:45 +00:00
import {
GroupCredentialsType ,
GroupLogResponseType ,
} from './textsecure/WebAPI' ;
2021-07-20 01:10:09 +00:00
import MessageSender from './textsecure/SendMessage' ;
import { CallbackResultType } from './textsecure/Types.d' ;
2020-09-09 02:25:05 +00:00
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message' ;
2020-09-24 20:57:54 +00:00
import { ConversationModel } from './models/conversations' ;
2021-03-03 20:09:58 +00:00
import { getGroupSizeHardLimit } from './groups/limits' ;
2021-05-05 16:39:16 +00:00
import { ourProfileKeyService } from './services/ourProfileKey' ;
2021-06-07 16:39:13 +00:00
import {
isGroupV1 as getIsGroupV1 ,
isGroupV2 as getIsGroupV2 ,
isMe ,
} from './util/whatTypeOfConversation' ;
2021-07-15 23:48:09 +00:00
import { handleMessageSend , SendTypesType } from './util/handleMessageSend' ;
2021-06-07 16:39:13 +00:00
import { getSendOptions } from './util/getSendOptions' ;
2021-06-22 14:46:42 +00:00
import * as Bytes from './Bytes' ;
import { SignalService as Proto } from './protobuf' ;
import AccessRequiredEnum = Proto . AccessControl . AccessRequired ;
2020-09-09 02:25:05 +00:00
2021-01-29 22:16:48 +00:00
export { joinViaLink } from './groups/joinViaLink' ;
2021-07-20 20:18:35 +00:00
type GroupV2AccessCreateChangeType = {
2020-10-06 17:06:34 +00:00
type : 'create' ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2AccessAttributesChangeType = {
2020-09-09 02:25:05 +00:00
type : 'access-attributes' ;
newPrivilege : number ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2AccessMembersChangeType = {
2020-09-09 02:25:05 +00:00
type : 'access-members' ;
newPrivilege : number ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2AccessInviteLinkChangeType = {
2020-12-18 19:27:43 +00:00
type : 'access-invite-link' ;
newPrivilege : number ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2AnnouncementsOnlyChangeType = {
type : 'announcements-only' ;
announcementsOnly : boolean ;
} ;
type GroupV2AvatarChangeType = {
2020-09-09 02:25:05 +00:00
type : 'avatar' ;
removed : boolean ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2TitleChangeType = {
2020-09-09 02:25:05 +00:00
type : 'title' ;
// Allow for null, because the title could be removed entirely
newTitle? : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2GroupLinkAddChangeType = {
2020-12-18 19:27:43 +00:00
type : 'group-link-add' ;
privilege : number ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2GroupLinkResetChangeType = {
2020-12-18 19:27:43 +00:00
type : 'group-link-reset' ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2GroupLinkRemoveChangeType = {
2020-12-18 19:27:43 +00:00
type : 'group-link-remove' ;
} ;
2020-09-09 02:25:05 +00:00
// No disappearing messages timer change type - message.expirationTimerUpdate used instead
2021-07-20 20:18:35 +00:00
type GroupV2MemberAddChangeType = {
2020-09-09 02:25:05 +00:00
type : 'member-add' ;
conversationId : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2MemberAddFromInviteChangeType = {
2020-09-09 02:25:05 +00:00
type : 'member-add-from-invite' ;
conversationId : string ;
2020-09-28 17:22:57 +00:00
inviter? : string ;
2020-09-09 02:25:05 +00:00
} ;
2021-07-20 20:18:35 +00:00
type GroupV2MemberAddFromLinkChangeType = {
2020-12-18 19:27:43 +00:00
type : 'member-add-from-link' ;
conversationId : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2MemberAddFromAdminApprovalChangeType = {
2020-12-18 19:27:43 +00:00
type : 'member-add-from-admin-approval' ;
conversationId : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2MemberPrivilegeChangeType = {
2020-09-09 02:25:05 +00:00
type : 'member-privilege' ;
conversationId : string ;
newPrivilege : number ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2MemberRemoveChangeType = {
2020-09-09 02:25:05 +00:00
type : 'member-remove' ;
conversationId : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2PendingAddOneChangeType = {
2020-09-09 02:25:05 +00:00
type : 'pending-add-one' ;
conversationId : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2PendingAddManyChangeType = {
2020-09-09 02:25:05 +00:00
type : 'pending-add-many' ;
count : number ;
} ;
// Note: pending-remove is only used if user didn't also join the group at the same time
2021-07-20 20:18:35 +00:00
type GroupV2PendingRemoveOneChangeType = {
2020-09-09 02:25:05 +00:00
type : 'pending-remove-one' ;
conversationId : string ;
inviter? : string ;
} ;
// Note: pending-remove is only used if user didn't also join the group at the same time
2021-07-20 20:18:35 +00:00
type GroupV2PendingRemoveManyChangeType = {
2020-09-09 02:25:05 +00:00
type : 'pending-remove-many' ;
count : number ;
inviter? : string ;
} ;
2021-07-20 20:18:35 +00:00
type GroupV2AdminApprovalAddOneChangeType = {
2020-12-18 19:27:43 +00:00
type : 'admin-approval-add-one' ;
conversationId : string ;
} ;
// Note: admin-approval-remove-one is only used if user didn't also join the group at
// the same time
2021-07-20 20:18:35 +00:00
type GroupV2AdminApprovalRemoveOneChangeType = {
2020-12-18 19:27:43 +00:00
type : 'admin-approval-remove-one' ;
conversationId : string ;
inviter? : string ;
} ;
2021-06-02 00:24:28 +00:00
export type GroupV2DescriptionChangeType = {
type : 'description' ;
removed? : boolean ;
2021-06-04 16:27:04 +00:00
// Adding this field; cannot remove previous field for backwards compatibility
description? : string ;
2021-06-02 00:24:28 +00:00
} ;
2020-12-18 19:27:43 +00:00
2020-09-09 02:25:05 +00:00
export type GroupV2ChangeDetailType =
| GroupV2AccessAttributesChangeType
2020-12-18 19:27:43 +00:00
| GroupV2AccessCreateChangeType
| GroupV2AccessInviteLinkChangeType
2020-09-09 02:25:05 +00:00
| GroupV2AccessMembersChangeType
2020-12-18 19:27:43 +00:00
| GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType
2021-07-20 20:18:35 +00:00
| GroupV2AnnouncementsOnlyChangeType
2020-12-18 19:27:43 +00:00
| GroupV2AvatarChangeType
2021-06-02 00:24:28 +00:00
| GroupV2DescriptionChangeType
2020-12-18 19:27:43 +00:00
| GroupV2GroupLinkAddChangeType
| GroupV2GroupLinkRemoveChangeType
2021-06-02 00:24:28 +00:00
| GroupV2GroupLinkResetChangeType
2020-09-09 02:25:05 +00:00
| GroupV2MemberAddChangeType
2020-12-18 19:27:43 +00:00
| GroupV2MemberAddFromAdminApprovalChangeType
2020-09-09 02:25:05 +00:00
| GroupV2MemberAddFromInviteChangeType
2020-12-18 19:27:43 +00:00
| GroupV2MemberAddFromLinkChangeType
2020-09-09 02:25:05 +00:00
| GroupV2MemberPrivilegeChangeType
2020-12-18 19:27:43 +00:00
| GroupV2MemberRemoveChangeType
2020-09-09 02:25:05 +00:00
| GroupV2PendingAddManyChangeType
2020-12-18 19:27:43 +00:00
| GroupV2PendingAddOneChangeType
| GroupV2PendingRemoveManyChangeType
2020-09-09 02:25:05 +00:00
| GroupV2PendingRemoveOneChangeType
2020-12-18 19:27:43 +00:00
| GroupV2TitleChangeType ;
2020-09-09 02:25:05 +00:00
export type GroupV2ChangeType = {
from ? : string ;
details : Array < GroupV2ChangeDetailType > ;
} ;
2021-04-09 18:20:37 +00:00
export type GroupFields = {
2021-06-22 14:46:42 +00:00
readonly id : Uint8Array ;
readonly secretParams : Uint8Array ;
readonly publicParams : Uint8Array ;
2021-04-09 18:20:37 +00:00
} ;
2021-06-22 14:46:42 +00:00
// TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array ;
2021-04-09 18:20:37 +00:00
const MAX_CACHED_GROUP_FIELDS = 100 ;
const groupFieldsCache = new LRU < string , GroupFields > ( {
max : MAX_CACHED_GROUP_FIELDS ,
} ) ;
2020-11-20 17:30:45 +00:00
const { updateConversation } = dataInterface ;
2020-09-09 02:25:05 +00:00
if ( ! isNumber ( MAX_MESSAGE_SCHEMA ) ) {
throw new Error (
'groups.ts: Unable to capture max message schema from js/modules/types/message'
) ;
}
type MemberType = {
profileKey : string ;
uuid : string ;
} ;
type UpdatesResultType = {
// The array of new messages to be added into the message timeline
groupChangeMessages : Array < MessageAttributesType > ;
// The set of members in the group, and we largely just pull profile keys for each,
// because the group membership is updated in newAttributes
members : Array < MemberType > ;
// To be merged into the conversation model
newAttributes : ConversationAttributesType ;
} ;
2021-03-03 20:09:58 +00:00
type UploadedAvatarType = {
data : ArrayBuffer ;
hash : string ;
key : string ;
} ;
2020-09-09 02:25:05 +00:00
// Constants
export const MASTER_KEY_LENGTH = 32 ;
2021-03-10 22:32:58 +00:00
const GROUP_TITLE_MAX_ENCRYPTED_BYTES = 1024 ;
2021-06-02 00:24:28 +00:00
const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192 ;
2020-11-16 21:34:41 +00:00
export const ID_V1_LENGTH = 16 ;
export const ID_LENGTH = 32 ;
2020-09-09 02:25:05 +00:00
const TEMPORAL_AUTH_REJECTED_CODE = 401 ;
const GROUP_ACCESS_DENIED_CODE = 403 ;
2020-11-20 17:30:45 +00:00
const GROUP_NONEXISTENT_CODE = 404 ;
2021-06-02 00:24:28 +00:00
const SUPPORTED_CHANGE_EPOCH = 2 ;
2021-01-29 22:16:48 +00:00
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR' ;
2021-01-29 21:19:24 +00:00
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16 ;
// Group Links
export function generateGroupInviteLinkPassword ( ) : ArrayBuffer {
return getRandomBytes ( GROUP_INVITE_LINK_PASSWORD_LENGTH ) ;
}
2021-01-29 22:16:48 +00:00
// Group Links
export async function getPreJoinGroupInfo (
inviteLinkPasswordBase64 : string ,
masterKeyBase64 : string
2021-06-22 14:46:42 +00:00
) : Promise < Proto.GroupJoinInfo > {
2021-01-29 22:16:48 +00:00
const data = window . Signal . Groups . deriveGroupFields (
2021-06-22 14:46:42 +00:00
Bytes . fromBase64 ( masterKeyBase64 )
2021-01-29 22:16:48 +00:00
) ;
return makeRequestWithTemporalRetry ( {
logId : ` groupv2( ${ data . id } ) ` ,
2021-06-22 14:46:42 +00:00
publicParams : Bytes.toBase64 ( data . publicParams ) ,
secretParams : Bytes.toBase64 ( data . secretParams ) ,
2021-01-29 22:16:48 +00:00
request : ( sender , options ) = >
sender . getGroupFromLink ( inviteLinkPasswordBase64 , options ) ,
} ) ;
2021-01-29 21:19:24 +00:00
}
export function buildGroupLink ( conversation : ConversationModel ) : string {
const { masterKey , groupInviteLinkPassword } = conversation . attributes ;
2021-06-22 14:46:42 +00:00
const bytes = Proto . GroupInviteLink . encode ( {
v1Contents : {
groupMasterKey : Bytes.fromBase64 ( masterKey ) ,
inviteLinkPassword : Bytes.fromBase64 ( groupInviteLinkPassword ) ,
} ,
} ) . finish ( ) ;
2021-01-29 21:19:24 +00:00
2021-06-22 14:46:42 +00:00
const hash = toWebSafeBase64 ( Bytes . toBase64 ( bytes ) ) ;
2021-01-29 21:19:24 +00:00
2021-02-01 22:57:42 +00:00
return ` https://signal.group/# ${ hash } ` ;
2021-01-29 21:19:24 +00:00
}
2020-09-09 02:25:05 +00:00
2021-01-29 22:16:48 +00:00
export function parseGroupLink (
hash : string
) : { masterKey : string ; inviteLinkPassword : string } {
const base64 = fromWebSafeBase64 ( hash ) ;
2021-06-22 14:46:42 +00:00
const buffer = Bytes . fromBase64 ( base64 ) ;
2021-01-29 22:16:48 +00:00
2021-06-22 14:46:42 +00:00
const inviteLinkProto = Proto . GroupInviteLink . decode ( buffer ) ;
2021-01-29 22:16:48 +00:00
if (
inviteLinkProto . contents !== 'v1Contents' ||
! inviteLinkProto . v1Contents
) {
const error = new Error (
'parseGroupLink: Parsed proto is missing v1Contents'
) ;
error . name = LINK_VERSION_ERROR ;
throw error ;
}
2021-06-22 14:46:42 +00:00
const {
groupMasterKey : groupMasterKeyRaw ,
inviteLinkPassword : inviteLinkPasswordRaw ,
} = inviteLinkProto . v1Contents ;
if ( ! groupMasterKeyRaw || ! groupMasterKeyRaw . length ) {
2021-01-29 22:16:48 +00:00
throw new Error ( 'v1Contents.groupMasterKey had no data!' ) ;
}
2021-06-22 14:46:42 +00:00
if ( ! inviteLinkPasswordRaw || ! inviteLinkPasswordRaw . length ) {
2021-01-29 22:16:48 +00:00
throw new Error ( 'v1Contents.inviteLinkPassword had no data!' ) ;
}
2021-06-22 14:46:42 +00:00
const masterKey = Bytes . toBase64 ( groupMasterKeyRaw ) ;
2021-01-29 22:16:48 +00:00
if ( masterKey . length !== 44 ) {
throw new Error ( ` masterKey had unexpected length ${ masterKey . length } ` ) ;
}
2021-06-22 14:46:42 +00:00
const inviteLinkPassword = Bytes . toBase64 ( inviteLinkPasswordRaw ) ;
2021-01-29 22:16:48 +00:00
if ( inviteLinkPassword . length === 0 ) {
throw new Error (
` inviteLinkPassword had unexpected length ${ inviteLinkPassword . length } `
) ;
}
return { masterKey , inviteLinkPassword } ;
}
2020-10-06 17:06:34 +00:00
// Group Modifications
2020-09-09 02:25:05 +00:00
2021-03-03 20:09:58 +00:00
async function uploadAvatar (
options : {
logId : string ;
publicParams : string ;
secretParams : string ;
} & ( { path : string } | { data : ArrayBuffer } )
) : Promise < UploadedAvatarType > {
const { logId , publicParams , secretParams } = options ;
2020-11-20 17:30:45 +00:00
try {
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
2021-03-03 20:09:58 +00:00
let data : ArrayBuffer ;
if ( 'data' in options ) {
( { data } = options ) ;
} else {
data = await window . Signal . Migrations . readAttachmentData ( options . path ) ;
}
2020-11-20 17:30:45 +00:00
const hash = await computeHash ( data ) ;
2021-06-22 14:46:42 +00:00
const blobPlaintext = Proto . GroupAttributeBlob . encode ( {
avatar : new FIXMEU8 ( data ) ,
} ) . finish ( ) ;
2020-11-20 17:30:45 +00:00
const ciphertext = encryptGroupBlob ( clientZkGroupCipher , blobPlaintext ) ;
const key = await makeRequestWithTemporalRetry ( {
logId : ` uploadGroupAvatar/ ${ logId } ` ,
publicParams ,
secretParams ,
2021-03-03 20:09:58 +00:00
request : ( sender , requestOptions ) = >
sender . uploadGroupAvatar ( ciphertext , requestOptions ) ,
2020-11-20 17:30:45 +00:00
} ) ;
return {
2021-03-03 20:09:58 +00:00
data ,
2020-11-20 17:30:45 +00:00
hash ,
2021-03-03 20:09:58 +00:00
key ,
2020-11-20 17:30:45 +00:00
} ;
} catch ( error ) {
window . log . warn (
` uploadAvatar/ ${ logId } Failed to upload avatar ` ,
error . stack
) ;
throw error ;
}
}
2021-03-09 19:16:56 +00:00
function buildGroupTitleBuffer (
clientZkGroupCipher : ClientZkGroupCipher ,
title : string
2021-06-22 14:46:42 +00:00
) : Uint8Array {
const titleBlobPlaintext = Proto . GroupAttributeBlob . encode ( {
title ,
} ) . finish ( ) ;
2021-03-10 22:32:58 +00:00
const result = encryptGroupBlob ( clientZkGroupCipher , titleBlobPlaintext ) ;
if ( result . byteLength > GROUP_TITLE_MAX_ENCRYPTED_BYTES ) {
throw new Error ( 'buildGroupTitleBuffer: encrypted group title is too long' ) ;
}
return result ;
2021-03-09 19:16:56 +00:00
}
2021-06-02 00:24:28 +00:00
function buildGroupDescriptionBuffer (
clientZkGroupCipher : ClientZkGroupCipher ,
description : string
2021-06-22 14:46:42 +00:00
) : Uint8Array {
const attrsBlobPlaintext = Proto . GroupAttributeBlob . encode ( {
descriptionText : description ,
} ) . finish ( ) ;
2021-06-02 00:24:28 +00:00
const result = encryptGroupBlob ( clientZkGroupCipher , attrsBlobPlaintext ) ;
if ( result . byteLength > GROUP_DESC_MAX_ENCRYPTED_BYTES ) {
throw new Error (
'buildGroupDescriptionBuffer: encrypted group title is too long'
) ;
}
return result ;
}
2021-03-03 20:09:58 +00:00
function buildGroupProto (
attributes : Pick <
ConversationAttributesType ,
| 'accessControl'
| 'expireTimer'
| 'id'
| 'membersV2'
| 'name'
| 'pendingMembersV2'
| 'publicParams'
| 'revision'
| 'secretParams'
> & {
avatarUrl? : string ;
}
2021-06-22 14:46:42 +00:00
) : Proto . Group {
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
2020-11-20 17:30:45 +00:00
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
) ;
2021-06-22 14:46:42 +00:00
const proto = new Proto . Group ( ) ;
2020-11-20 17:30:45 +00:00
2021-06-22 14:46:42 +00:00
proto . publicKey = Bytes . fromBase64 ( publicParams ) ;
2020-11-20 17:30:45 +00:00
proto . version = attributes . revision || 0 ;
2021-03-09 19:16:56 +00:00
if ( attributes . name ) {
proto . title = buildGroupTitleBuffer ( clientZkGroupCipher , attributes . name ) ;
}
2020-11-20 17:30:45 +00:00
2021-03-03 20:09:58 +00:00
if ( attributes . avatarUrl ) {
proto . avatar = attributes . avatarUrl ;
2020-11-20 17:30:45 +00:00
}
if ( attributes . expireTimer ) {
2021-06-22 14:46:42 +00:00
const timerBlobPlaintext = Proto . GroupAttributeBlob . encode ( {
disappearingMessagesDuration : attributes.expireTimer ,
} ) . finish ( ) ;
2020-11-20 17:30:45 +00:00
proto . disappearingMessagesTimer = encryptGroupBlob (
clientZkGroupCipher ,
timerBlobPlaintext
) ;
}
2021-06-22 14:46:42 +00:00
const accessControl = new Proto . AccessControl ( ) ;
2020-11-20 17:30:45 +00:00
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 = > {
2021-06-22 14:46:42 +00:00
const member = new Proto . Member ( ) ;
2020-11-20 17:30:45 +00:00
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 (
2021-03-01 19:55:49 +00:00
` buildGroupProto/ ${ logId } : member was missing profileKeyCredential! `
2020-11-20 17:30:45 +00:00
) ;
}
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 ) ;
2020-12-18 19:27:43 +00:00
proto . membersPendingProfileKey = ( attributes . pendingMembersV2 || [ ] ) . map (
item = > {
2021-06-22 14:46:42 +00:00
const pendingMember = new Proto . MemberPendingProfileKey ( ) ;
const member = new Proto . Member ( ) ;
2020-11-20 17:30:45 +00:00
2020-12-18 19:27:43 +00:00
const conversation = window . ConversationController . get (
item . conversationId
) ;
if ( ! conversation ) {
throw new Error ( 'buildGroupProto: no conversation for pending member!' ) ;
}
2020-11-20 17:30:45 +00:00
2020-12-18 19:27:43 +00:00
const uuid = conversation . get ( 'uuid' ) ;
if ( ! uuid ) {
throw new Error ( 'buildGroupProto: pending member was missing uuid!' ) ;
}
2020-11-20 17:30:45 +00:00
2020-12-18 19:27:43 +00:00
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
member . userId = uuidCipherTextBuffer ;
member . role = item . role || MEMBER_ROLE_ENUM . DEFAULT ;
2020-11-20 17:30:45 +00:00
2020-12-18 19:27:43 +00:00
pendingMember . member = member ;
pendingMember . timestamp = item . timestamp ;
pendingMember . addedByUserId = ourUuidCipherTextBuffer ;
2020-11-20 17:30:45 +00:00
2020-12-18 19:27:43 +00:00
return pendingMember ;
}
) ;
2020-11-20 17:30:45 +00:00
return proto ;
}
2021-03-11 21:29:31 +00:00
export async function buildAddMembersChange (
conversation : Pick <
ConversationAttributesType ,
'id' | 'publicParams' | 'revision' | 'secretParams'
> ,
conversationIds : ReadonlyArray < string >
2021-06-22 14:46:42 +00:00
) : Promise < undefined | Proto.GroupChange.Actions > {
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2021-03-11 21:29:31 +00:00
const { id , publicParams , revision , secretParams } = conversation ;
const logId = ` groupv2( ${ id } ) ` ;
if ( ! publicParams ) {
throw new Error (
` buildAddMembersChange/ ${ logId } : attributes were missing publicParams! `
) ;
}
if ( ! secretParams ) {
throw new Error (
` buildAddMembersChange/ ${ logId } : attributes were missing secretParams! `
) ;
}
const newGroupVersion = ( revision || 0 ) + 1 ;
const serverPublicParamsBase64 = window . getServerPublicParams ( ) ;
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
const ourConversationId = window . ConversationController . getOurConversationIdOrThrow ( ) ;
const ourConversation = window . ConversationController . get ( ourConversationId ) ;
const ourUuid = ourConversation ? . get ( 'uuid' ) ;
if ( ! ourUuid ) {
throw new Error (
` buildAddMembersChange/ ${ logId } : unable to find our own UUID! `
) ;
}
const ourUuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , ourUuid ) ;
const now = Date . now ( ) ;
2021-06-22 14:46:42 +00:00
const addMembers : Array < Proto.GroupChange.Actions.AddMemberAction > = [ ] ;
const addPendingMembers : Array < Proto.GroupChange.Actions.AddMemberPendingProfileKeyAction > = [ ] ;
2021-03-11 21:29:31 +00:00
await Promise . all (
conversationIds . map ( async conversationId = > {
const contact = window . ConversationController . get ( conversationId ) ;
if ( ! contact ) {
assert (
false ,
` buildAddMembersChange/ ${ logId } : missing local contact, skipping `
) ;
return ;
}
const uuid = contact . get ( 'uuid' ) ;
if ( ! uuid ) {
assert ( false , ` buildAddMembersChange/ ${ logId } : missing UUID; skipping ` ) ;
return ;
}
// Refresh our local data to be sure
if (
! contact . get ( 'capabilities' ) ? . gv2 ||
! contact . get ( 'profileKey' ) ||
! contact . get ( 'profileKeyCredential' )
) {
await contact . getProfiles ( ) ;
}
if ( ! contact . get ( 'capabilities' ) ? . gv2 ) {
assert (
false ,
` buildAddMembersChange/ ${ logId } : member is missing GV2 capability; skipping `
) ;
return ;
}
const profileKey = contact . get ( 'profileKey' ) ;
const profileKeyCredential = contact . get ( 'profileKeyCredential' ) ;
if ( ! profileKey ) {
assert (
false ,
` buildAddMembersChange/ ${ logId } : member is missing profile key; skipping `
) ;
return ;
}
2021-06-22 14:46:42 +00:00
const member = new Proto . Member ( ) ;
2021-03-11 21:29:31 +00:00
member . userId = encryptUuid ( clientZkGroupCipher , uuid ) ;
member . role = MEMBER_ROLE_ENUM . DEFAULT ;
member . joinedAtVersion = newGroupVersion ;
// This is inspired by [Android's equivalent code][0].
//
// [0]: https://github.com/signalapp/Signal-Android/blob/2be306867539ab1526f0e49d1aa7bd61e783d23f/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java#L152-L174
if ( profileKey && profileKeyCredential ) {
member . presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredential ,
secretParams
) ;
2021-06-22 14:46:42 +00:00
const addMemberAction = new Proto . GroupChange . Actions . AddMemberAction ( ) ;
2021-03-11 21:29:31 +00:00
addMemberAction . added = member ;
addMemberAction . joinFromInviteLink = false ;
addMembers . push ( addMemberAction ) ;
} else {
2021-06-22 14:46:42 +00:00
const memberPendingProfileKey = new Proto . MemberPendingProfileKey ( ) ;
2021-03-11 21:29:31 +00:00
memberPendingProfileKey . member = member ;
memberPendingProfileKey . addedByUserId = ourUuidCipherTextBuffer ;
memberPendingProfileKey . timestamp = now ;
2021-06-22 14:46:42 +00:00
const addPendingMemberAction = new Proto . GroupChange . Actions . AddMemberPendingProfileKeyAction ( ) ;
2021-03-11 21:29:31 +00:00
addPendingMemberAction . added = memberPendingProfileKey ;
addPendingMembers . push ( addPendingMemberAction ) ;
}
} )
) ;
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-03-11 21:29:31 +00:00
if ( ! addMembers . length && ! addPendingMembers . length ) {
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
// will be logged.
return undefined ;
}
if ( addMembers . length ) {
actions . addMembers = addMembers ;
}
if ( addPendingMembers . length ) {
actions . addPendingMembers = addPendingMembers ;
}
actions . version = newGroupVersion ;
return actions ;
}
2021-03-09 19:16:56 +00:00
export async function buildUpdateAttributesChange (
conversation : Pick <
ConversationAttributesType ,
'id' | 'revision' | 'publicParams' | 'secretParams'
> ,
attributes : Readonly < {
avatar? : undefined | ArrayBuffer ;
2021-06-02 00:24:28 +00:00
description? : string ;
2021-03-09 19:16:56 +00:00
title? : string ;
} >
2021-06-22 14:46:42 +00:00
) : Promise < undefined | Proto.GroupChange.Actions > {
2021-03-09 19:16:56 +00:00
const { publicParams , secretParams , revision , id } = conversation ;
const logId = ` groupv2( ${ id } ) ` ;
if ( ! publicParams ) {
throw new Error (
` buildUpdateAttributesChange/ ${ logId } : attributes were missing publicParams! `
) ;
}
if ( ! secretParams ) {
throw new Error (
` buildUpdateAttributesChange/ ${ logId } : attributes were missing secretParams! `
) ;
}
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-03-09 19:16:56 +00:00
let hasChangedSomething = false ;
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
// There are three possible states here:
//
// 1. 'avatar' not in attributes: we don't want to change the avatar.
// 2. attributes.avatar === undefined: we want to clear the avatar.
// 3. attributes.avatar !== undefined: we want to update the avatar.
if ( 'avatar' in attributes ) {
hasChangedSomething = true ;
2021-06-22 14:46:42 +00:00
actions . modifyAvatar = new Proto . GroupChange . Actions . ModifyAvatarAction ( ) ;
2021-03-09 19:16:56 +00:00
const { avatar } = attributes ;
if ( avatar ) {
const uploadedAvatar = await uploadAvatar ( {
data : avatar ,
logId ,
publicParams ,
secretParams ,
} ) ;
actions . modifyAvatar . avatar = uploadedAvatar . key ;
}
// If we don't set `actions.modifyAvatar.avatar`, it will be cleared.
}
const { title } = attributes ;
if ( title ) {
hasChangedSomething = true ;
2021-06-22 14:46:42 +00:00
actions . modifyTitle = new Proto . GroupChange . Actions . ModifyTitleAction ( ) ;
2021-03-09 19:16:56 +00:00
actions . modifyTitle . title = buildGroupTitleBuffer (
clientZkGroupCipher ,
title
) ;
}
2021-06-02 00:24:28 +00:00
const { description } = attributes ;
if ( typeof description === 'string' ) {
hasChangedSomething = true ;
2021-06-22 14:46:42 +00:00
actions . modifyDescription = new Proto . GroupChange . Actions . ModifyDescriptionAction ( ) ;
2021-06-02 00:24:28 +00:00
actions . modifyDescription . descriptionBytes = buildGroupDescriptionBuffer (
clientZkGroupCipher ,
description
) ;
}
2021-03-09 19:16:56 +00:00
if ( ! hasChangedSomething ) {
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
// will be logged.
return undefined ;
}
actions . version = ( revision || 0 ) + 1 ;
return actions ;
}
2020-09-09 02:25:05 +00:00
export function buildDisappearingMessagesTimerChange ( {
expireTimer ,
group ,
} : {
2021-06-22 14:46:42 +00:00
expireTimer : number ;
2020-09-09 02:25:05 +00:00
group : ConversationAttributesType ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
const blob = new Proto . GroupAttributeBlob ( ) ;
2020-09-09 02:25:05 +00:00
blob . disappearingMessagesDuration = expireTimer ;
if ( ! group . secretParams ) {
throw new Error (
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
2021-06-22 14:46:42 +00:00
const blobPlaintext = Proto . GroupAttributeBlob . encode ( blob ) . finish ( ) ;
2020-09-09 02:25:05 +00:00
const blobCipherText = encryptGroupBlob ( clientZkGroupCipher , blobPlaintext ) ;
2021-06-22 14:46:42 +00:00
const timerAction = new Proto . GroupChange . Actions . ModifyDisappearingMessagesTimerAction ( ) ;
2020-09-09 02:25:05 +00:00
timerAction . timer = blobCipherText ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyDisappearingMessagesTimer = timerAction ;
return actions ;
}
2021-01-29 21:19:24 +00:00
export function buildInviteLinkPasswordChange (
group : ConversationAttributesType ,
inviteLinkPassword : string
2021-06-22 14:46:42 +00:00
) : Proto . GroupChange . Actions {
const inviteLinkPasswordAction = new Proto . GroupChange . Actions . ModifyInviteLinkPasswordAction ( ) ;
inviteLinkPasswordAction . inviteLinkPassword = Bytes . fromBase64 (
2021-01-29 21:19:24 +00:00
inviteLinkPassword
) ;
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyInviteLinkPassword = inviteLinkPasswordAction ;
return actions ;
}
export function buildNewGroupLinkChange (
group : ConversationAttributesType ,
inviteLinkPassword : string ,
addFromInviteLinkAccess : AccessRequiredEnum
2021-06-22 14:46:42 +00:00
) : Proto . GroupChange . Actions {
const accessControlAction = new Proto . GroupChange . Actions . ModifyAddFromInviteLinkAccessControlAction ( ) ;
2021-01-29 21:19:24 +00:00
accessControlAction . addFromInviteLinkAccess = addFromInviteLinkAccess ;
2021-06-22 14:46:42 +00:00
const inviteLinkPasswordAction = new Proto . GroupChange . Actions . ModifyInviteLinkPasswordAction ( ) ;
inviteLinkPasswordAction . inviteLinkPassword = Bytes . fromBase64 (
2021-01-29 21:19:24 +00:00
inviteLinkPassword
) ;
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAddFromInviteLinkAccess = accessControlAction ;
actions . modifyInviteLinkPassword = inviteLinkPasswordAction ;
return actions ;
}
export function buildAccessControlAddFromInviteLinkChange (
group : ConversationAttributesType ,
value : AccessRequiredEnum
2021-06-22 14:46:42 +00:00
) : Proto . GroupChange . Actions {
const accessControlAction = new Proto . GroupChange . Actions . ModifyAddFromInviteLinkAccessControlAction ( ) ;
2021-01-29 21:19:24 +00:00
accessControlAction . addFromInviteLinkAccess = value ;
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAddFromInviteLinkAccess = accessControlAction ;
return actions ;
}
2021-07-20 20:18:35 +00:00
export function buildAnnouncementsOnlyChange (
group : ConversationAttributesType ,
value : boolean
) : Proto . GroupChange . Actions {
const action = new Proto . GroupChange . Actions . ModifyAnnouncementsOnlyAction ( ) ;
action . announcementsOnly = value ;
const actions = new Proto . GroupChange . Actions ( ) ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAnnouncementsOnly = action ;
return actions ;
}
2021-01-29 21:19:24 +00:00
export function buildAccessControlAttributesChange (
group : ConversationAttributesType ,
value : AccessRequiredEnum
2021-06-22 14:46:42 +00:00
) : Proto . GroupChange . Actions {
const accessControlAction = new Proto . GroupChange . Actions . ModifyAttributesAccessControlAction ( ) ;
2021-01-29 21:19:24 +00:00
accessControlAction . attributesAccess = value ;
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAttributesAccess = accessControlAction ;
return actions ;
}
export function buildAccessControlMembersChange (
group : ConversationAttributesType ,
value : AccessRequiredEnum
2021-06-22 14:46:42 +00:00
) : Proto . GroupChange . Actions {
const accessControlAction = new Proto . GroupChange . Actions . ModifyMembersAccessControlAction ( ) ;
2021-01-29 21:19:24 +00:00
accessControlAction . membersAccess = value ;
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyMemberAccess = accessControlAction ;
return actions ;
}
// TODO AND-1101
export function buildDeletePendingAdminApprovalMemberChange ( {
2020-10-06 17:06:34 +00:00
group ,
2021-01-29 21:19:24 +00:00
uuid ,
2020-10-06 17:06:34 +00:00
} : {
2021-01-29 21:19:24 +00:00
group : ConversationAttributesType ;
2020-10-06 17:06:34 +00:00
uuid : string ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
if ( ! group . secretParams ) {
throw new Error (
'buildDeletePendingAdminApprovalMemberChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
2021-06-22 14:46:42 +00:00
const deleteMemberPendingAdminApproval = new Proto . GroupChange . Actions . DeleteMemberPendingAdminApprovalAction ( ) ;
2021-01-29 21:19:24 +00:00
deleteMemberPendingAdminApproval . deletedUserId = uuidCipherTextBuffer ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . deleteMemberPendingAdminApprovals = [
deleteMemberPendingAdminApproval ,
] ;
return actions ;
}
2021-01-29 22:16:48 +00:00
export function buildAddPendingAdminApprovalMemberChange ( {
group ,
profileKeyCredentialBase64 ,
serverPublicParamsBase64 ,
} : {
group : ConversationAttributesType ;
profileKeyCredentialBase64 : string ;
serverPublicParamsBase64 : string ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 22:16:48 +00:00
if ( ! group . secretParams ) {
throw new Error (
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
) ;
}
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
2021-06-22 14:46:42 +00:00
const addMemberPendingAdminApproval = new Proto . GroupChange . Actions . AddMemberPendingAdminApprovalAction ( ) ;
2021-01-29 22:16:48 +00:00
const presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredentialBase64 ,
group . secretParams
) ;
2021-06-22 14:46:42 +00:00
const added = new Proto . MemberPendingAdminApproval ( ) ;
2021-01-29 22:16:48 +00:00
added . presentation = presentation ;
addMemberPendingAdminApproval . added = added ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . addMemberPendingAdminApprovals = [ addMemberPendingAdminApproval ] ;
return actions ;
}
export function buildAddMember ( {
group ,
profileKeyCredentialBase64 ,
serverPublicParamsBase64 ,
} : {
group : ConversationAttributesType ;
profileKeyCredentialBase64 : string ;
serverPublicParamsBase64 : string ;
joinFromInviteLink? : boolean ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2021-01-29 22:16:48 +00:00
2021-06-22 14:46:42 +00:00
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 22:16:48 +00:00
if ( ! group . secretParams ) {
throw new Error ( 'buildAddMember: group was missing secretParams!' ) ;
}
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
2021-06-22 14:46:42 +00:00
const addMember = new Proto . GroupChange . Actions . AddMemberAction ( ) ;
2021-01-29 22:16:48 +00:00
const presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredentialBase64 ,
group . secretParams
) ;
2021-06-22 14:46:42 +00:00
const added = new Proto . Member ( ) ;
2021-01-29 22:16:48 +00:00
added . presentation = presentation ;
added . role = MEMBER_ROLE_ENUM . DEFAULT ;
addMember . added = added ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . addMembers = [ addMember ] ;
return actions ;
}
2021-01-29 21:19:24 +00:00
export function buildDeletePendingMemberChange ( {
uuids ,
group ,
} : {
uuids : Array < string > ;
2020-10-06 17:06:34 +00:00
group : ConversationAttributesType ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2020-10-06 17:06:34 +00:00
if ( ! group . secretParams ) {
throw new Error (
'buildDeletePendingMemberChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
2021-01-29 21:19:24 +00:00
const deletePendingMembers = uuids . map ( uuid = > {
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
2021-06-22 14:46:42 +00:00
const deletePendingMember = new Proto . GroupChange . Actions . DeleteMemberPendingProfileKeyAction ( ) ;
2021-01-29 21:19:24 +00:00
deletePendingMember . deletedUserId = uuidCipherTextBuffer ;
return deletePendingMember ;
} ) ;
2020-10-06 17:06:34 +00:00
actions . version = ( group . revision || 0 ) + 1 ;
2021-01-29 21:19:24 +00:00
actions . deletePendingMembers = deletePendingMembers ;
2020-10-06 17:06:34 +00:00
return actions ;
}
export function buildDeleteMemberChange ( {
uuid ,
group ,
} : {
uuid : string ;
group : ConversationAttributesType ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2020-10-06 17:06:34 +00:00
if ( ! group . secretParams ) {
throw new Error ( 'buildDeleteMemberChange: group was missing secretParams!' ) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
2021-06-22 14:46:42 +00:00
const deleteMember = new Proto . GroupChange . Actions . DeleteMemberAction ( ) ;
2020-10-06 17:06:34 +00:00
deleteMember . deletedUserId = uuidCipherTextBuffer ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . deleteMembers = [ deleteMember ] ;
return actions ;
}
2021-01-29 21:19:24 +00:00
export function buildModifyMemberRoleChange ( {
uuid ,
group ,
role ,
} : {
uuid : string ;
group : ConversationAttributesType ;
role : number ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
if ( ! group . secretParams ) {
throw new Error ( 'buildMakeAdminChange: group was missing secretParams!' ) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
2021-06-22 14:46:42 +00:00
const toggleAdmin = new Proto . GroupChange . Actions . ModifyMemberRoleAction ( ) ;
2021-01-29 21:19:24 +00:00
toggleAdmin . userId = uuidCipherTextBuffer ;
toggleAdmin . role = role ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyMemberRoles = [ toggleAdmin ] ;
return actions ;
}
export function buildPromotePendingAdminApprovalMemberChange ( {
group ,
uuid ,
} : {
group : ConversationAttributesType ;
uuid : string ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
const actions = new Proto . GroupChange . Actions ( ) ;
2021-01-29 21:19:24 +00:00
if ( ! group . secretParams ) {
throw new Error (
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
2021-06-22 14:46:42 +00:00
const promotePendingMember = new Proto . GroupChange . Actions . PromoteMemberPendingAdminApprovalAction ( ) ;
2021-01-29 21:19:24 +00:00
promotePendingMember . userId = uuidCipherTextBuffer ;
promotePendingMember . role = MEMBER_ROLE_ENUM . DEFAULT ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . promoteMemberPendingAdminApprovals = [ promotePendingMember ] ;
return actions ;
}
2020-10-06 17:06:34 +00:00
export function buildPromoteMemberChange ( {
group ,
profileKeyCredentialBase64 ,
serverPublicParamsBase64 ,
} : {
group : ConversationAttributesType ;
profileKeyCredentialBase64 : string ;
serverPublicParamsBase64 : string ;
2021-06-22 14:46:42 +00:00
} ) : Proto . GroupChange . Actions {
const actions = new Proto . GroupChange . Actions ( ) ;
2020-10-06 17:06:34 +00:00
if ( ! group . secretParams ) {
throw new Error (
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
) ;
}
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
const presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredentialBase64 ,
group . secretParams
) ;
2021-06-22 14:46:42 +00:00
const promotePendingMember = new Proto . GroupChange . Actions . PromoteMemberPendingProfileKeyAction ( ) ;
2020-10-06 17:06:34 +00:00
promotePendingMember . presentation = presentation ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . promotePendingMembers = [ promotePendingMember ] ;
return actions ;
}
2020-09-09 02:25:05 +00:00
export async function uploadGroupChange ( {
actions ,
group ,
2021-01-29 22:16:48 +00:00
inviteLinkPassword ,
2020-09-09 02:25:05 +00:00
} : {
2021-06-22 14:46:42 +00:00
actions : Proto.GroupChange.IActions ;
2020-09-09 02:25:05 +00:00
group : ConversationAttributesType ;
2021-01-29 22:16:48 +00:00
inviteLinkPassword? : string ;
2021-06-22 14:46:42 +00:00
} ) : Promise < Proto.IGroupChange > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
if ( ! group . secretParams ) {
throw new Error ( 'uploadGroupChange: group was missing secretParams!' ) ;
}
if ( ! group . publicParams ) {
throw new Error ( 'uploadGroupChange: group was missing publicParams!' ) ;
}
2020-11-20 17:30:45 +00:00
return makeRequestWithTemporalRetry ( {
logId : ` uploadGroupChange/ ${ logId } ` ,
publicParams : group.publicParams ,
secretParams : group.secretParams ,
2021-01-29 22:16:48 +00:00
request : ( sender , options ) = >
sender . modifyGroup ( actions , options , inviteLinkPassword ) ,
2020-11-20 17:30:45 +00:00
} ) ;
2020-09-09 02:25:05 +00:00
}
2021-01-29 22:16:48 +00:00
export async function modifyGroupV2 ( {
conversation ,
createGroupChange ,
2021-03-24 21:28:55 +00:00
extraConversationsForSend ,
2021-01-29 22:16:48 +00:00
inviteLinkPassword ,
name ,
} : {
conversation : ConversationModel ;
2021-06-22 14:46:42 +00:00
createGroupChange : ( ) = > Promise < Proto.GroupChange.Actions | undefined > ;
2021-03-24 21:28:55 +00:00
extraConversationsForSend? : Array < string > ;
2021-01-29 22:16:48 +00:00
inviteLinkPassword? : string ;
name : string ;
} ) : Promise < void > {
const idLog = ` ${ name } / ${ conversation . idForLogging ( ) } ` ;
2021-06-07 16:39:13 +00:00
if ( ! getIsGroupV2 ( conversation . attributes ) ) {
2021-01-29 22:16:48 +00:00
throw new Error (
` modifyGroupV2/ ${ idLog } : Called for non-GroupV2 conversation `
) ;
}
const ONE_MINUTE = 1000 * 60 ;
const startTime = Date . now ( ) ;
const timeoutTime = startTime + ONE_MINUTE ;
const MAX_ATTEMPTS = 5 ;
for ( let attempt = 0 ; attempt < MAX_ATTEMPTS ; attempt += 1 ) {
window . log . info ( ` modifyGroupV2/ ${ idLog } : Starting attempt ${ attempt } ` ) ;
try {
// eslint-disable-next-line no-await-in-loop
await window . waitForEmptyEventQueue ( ) ;
window . log . info ( ` modifyGroupV2/ ${ idLog } : Queuing attempt ${ attempt } ` ) ;
// eslint-disable-next-line no-await-in-loop
2021-06-14 21:55:14 +00:00
await conversation . queueJob ( 'modifyGroupV2' , async ( ) = > {
2021-01-29 22:16:48 +00:00
window . log . info ( ` modifyGroupV2/ ${ idLog } : Running attempt ${ attempt } ` ) ;
const actions = await createGroupChange ( ) ;
if ( ! actions ) {
window . log . warn (
` modifyGroupV2/ ${ idLog } : No change actions. Returning early. `
) ;
return ;
}
// The new revision has to be exactly one more than the current revision
// or it won't upload properly, and it won't apply in maybeUpdateGroup
const currentRevision = conversation . get ( 'revision' ) ;
const newRevision = actions . version ;
if ( ( currentRevision || 0 ) + 1 !== newRevision ) {
throw new Error (
` modifyGroupV2/ ${ idLog } : Revision mismatch - ${ currentRevision } to ${ newRevision } . `
) ;
}
// Upload. If we don't have permission, the server will return an error here.
const groupChange = await window . Signal . Groups . uploadGroupChange ( {
actions ,
inviteLinkPassword ,
group : conversation.attributes ,
} ) ;
2021-06-22 14:46:42 +00:00
const groupChangeBuffer = Proto . GroupChange . encode (
groupChange
) . finish ( ) ;
const groupChangeBase64 = Bytes . toBase64 ( groupChangeBuffer ) ;
2021-01-29 22:16:48 +00:00
// Apply change locally, just like we would with an incoming change. This will
// change conversation state and add change notifications to the timeline.
await window . Signal . Groups . maybeUpdateGroup ( {
conversation ,
groupChangeBase64 ,
newRevision ,
} ) ;
// Send message to notify group members (including pending members) of change
const profileKey = conversation . get ( 'profileSharing' )
2021-05-05 16:39:16 +00:00
? await ourProfileKeyService . get ( )
2021-01-29 22:16:48 +00:00
: undefined ;
2021-06-07 16:39:13 +00:00
const sendOptions = await getSendOptions ( conversation . attributes ) ;
2021-01-29 22:16:48 +00:00
const timestamp = Date . now ( ) ;
2021-06-22 14:46:42 +00:00
const { ContentHint } = Proto . UnidentifiedSenderMessage . Message ;
2021-01-29 22:16:48 +00:00
2021-06-07 16:39:13 +00:00
const promise = handleMessageSend (
2021-07-02 18:34:17 +00:00
window . Signal . Util . sendToGroup ( {
groupSendOptions : {
2021-01-29 22:16:48 +00:00
groupV2 : conversation.getGroupV2Info ( {
2021-06-22 14:46:42 +00:00
groupChange : typedArrayToArrayBuffer ( groupChangeBuffer ) ,
2021-01-29 22:16:48 +00:00
includePendingMembers : true ,
2021-03-24 21:28:55 +00:00
extraConversationsForSend ,
2021-01-29 22:16:48 +00:00
} ) ,
timestamp ,
profileKey ,
} ,
2021-05-25 22:40:04 +00:00
conversation ,
2021-07-15 23:48:09 +00:00
contentHint : ContentHint.RESENDABLE ,
messageId : undefined ,
2021-07-02 18:34:17 +00:00
sendOptions ,
2021-07-15 23:48:09 +00:00
sendType : 'groupChange' ,
} ) ,
{ messageIds : [ ] , sendType : 'groupChange' }
2021-01-29 22:16:48 +00:00
) ;
// We don't save this message; we just use it to ensure that a sync message is
// sent to our linked devices.
const m = new window . Whisper . Message ( ( {
conversationId : conversation.id ,
type : 'not-to-save' ,
sent_at : timestamp ,
received_at : timestamp ,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown ) as MessageAttributesType ) ;
// This is to ensure that the functions in send() and sendSyncMessage()
// don't save anything to the database.
m . doNotSave = true ;
await m . send ( promise ) ;
} ) ;
// If we've gotten here with no error, we exit!
window . log . info (
` modifyGroupV2/ ${ idLog } : Update complete, with attempt ${ attempt } ! `
) ;
break ;
} catch ( error ) {
if ( error . code === 409 && Date . now ( ) <= timeoutTime ) {
window . log . info (
` modifyGroupV2/ ${ idLog } : Conflict while updating. Trying again... `
) ;
// eslint-disable-next-line no-await-in-loop
await conversation . fetchLatestGroupV2Data ( ) ;
} else if ( error . code === 409 ) {
window . log . error (
` modifyGroupV2/ ${ idLog } : Conflict while updating. Timed out; not retrying. `
) ;
// We don't wait here because we're breaking out of the loop immediately.
conversation . fetchLatestGroupV2Data ( ) ;
throw error ;
} else {
const errorString = error && error . stack ? error.stack : error ;
window . log . error (
` modifyGroupV2/ ${ idLog } : Error updating: ${ errorString } `
) ;
throw error ;
}
}
}
}
2020-09-09 02:25:05 +00:00
// Utility
2021-01-29 22:16:48 +00:00
export function idForLogging ( groupId : string | undefined ) : string {
return ` groupv2( ${ groupId } ) ` ;
2020-11-20 17:30:45 +00:00
}
2021-06-22 14:46:42 +00:00
export function deriveGroupFields ( masterKey : Uint8Array ) : GroupFields {
2021-07-09 19:36:10 +00:00
if ( masterKey . length !== MASTER_KEY_LENGTH ) {
throw new Error (
` deriveGroupFields: masterKey had length ${ masterKey . length } , ` +
` expected ${ MASTER_KEY_LENGTH } `
) ;
}
2021-06-22 14:46:42 +00:00
const cacheKey = Bytes . toBase64 ( masterKey ) ;
2021-04-09 18:20:37 +00:00
const cached = groupFieldsCache . get ( cacheKey ) ;
if ( cached ) {
return cached ;
}
window . log . info ( 'deriveGroupFields: cache miss' ) ;
2020-09-09 02:25:05 +00:00
const secretParams = deriveGroupSecretParams ( masterKey ) ;
const publicParams = deriveGroupPublicParams ( secretParams ) ;
const id = deriveGroupID ( secretParams ) ;
2021-04-09 18:20:37 +00:00
const fresh = {
2020-09-09 02:25:05 +00:00
id ,
secretParams ,
publicParams ,
} ;
2021-04-09 18:20:37 +00:00
groupFieldsCache . set ( cacheKey , fresh ) ;
return fresh ;
2020-09-09 02:25:05 +00:00
}
2020-11-13 19:57:55 +00:00
async function makeRequestWithTemporalRetry < T > ( {
logId ,
publicParams ,
secretParams ,
request ,
} : {
logId : string ;
publicParams : string ;
secretParams : string ;
request : ( sender : MessageSender , options : GroupCredentialsType ) = > Promise < T > ;
} ) : Promise < T > {
const data = window . storage . get ( GROUP_CREDENTIALS_KEY ) ;
if ( ! data ) {
throw new Error (
` makeRequestWithTemporalRetry/ ${ logId } : No group credentials! `
) ;
}
const groupCredentials = getCredentialsForToday ( data ) ;
const sender = window . textsecure . messaging ;
if ( ! sender ) {
throw new Error (
` makeRequestWithTemporalRetry/ ${ logId } : textsecure.messaging is not available! `
) ;
}
const todayOptions = getGroupCredentials ( {
authCredentialBase64 : groupCredentials.today.credential ,
groupPublicParamsBase64 : publicParams ,
groupSecretParamsBase64 : secretParams ,
serverPublicParamsBase64 : window.getServerPublicParams ( ) ,
} ) ;
try {
return await request ( sender , todayOptions ) ;
} catch ( todayError ) {
if ( todayError . code === TEMPORAL_AUTH_REJECTED_CODE ) {
window . log . warn (
` makeRequestWithTemporalRetry/ ${ logId } : Trying again with tomorrow's credentials `
) ;
const tomorrowOptions = getGroupCredentials ( {
authCredentialBase64 : groupCredentials.tomorrow.credential ,
groupPublicParamsBase64 : publicParams ,
groupSecretParamsBase64 : secretParams ,
serverPublicParamsBase64 : window.getServerPublicParams ( ) ,
} ) ;
return request ( sender , tomorrowOptions ) ;
}
throw todayError ;
}
}
export async function fetchMembershipProof ( {
publicParams ,
secretParams ,
} : {
publicParams : string ;
secretParams : string ;
} ) : Promise < string | undefined > {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
if ( ! publicParams ) {
throw new Error ( 'fetchMembershipProof: group was missing publicParams!' ) ;
}
if ( ! secretParams ) {
throw new Error ( 'fetchMembershipProof: group was missing secretParams!' ) ;
}
const response = await makeRequestWithTemporalRetry ( {
logId : 'fetchMembershipProof' ,
publicParams ,
secretParams ,
request : ( sender , options ) = > sender . getGroupMembershipToken ( options ) ,
} ) ;
return response . token ;
}
2021-03-03 20:09:58 +00:00
// Creating a group
export async function createGroupV2 ( {
name ,
avatar ,
2021-06-25 23:52:56 +00:00
expireTimer ,
2021-03-03 20:09:58 +00:00
conversationIds ,
} : Readonly < {
name : string ;
avatar : undefined | ArrayBuffer ;
2021-06-25 23:52:56 +00:00
expireTimer : undefined | number ;
2021-03-03 20:09:58 +00:00
conversationIds : Array < string > ;
} > ) : Promise < ConversationModel > {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
if ( ! isStorageWriteFeatureEnabled ( ) ) {
throw new Error (
'createGroupV2: storage service write is not enabled. Cannot create the group'
) ;
}
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2021-03-03 20:09:58 +00:00
2021-06-22 14:46:42 +00:00
const masterKeyBuffer = new FIXMEU8 ( getRandomBytes ( 32 ) ) ;
2021-03-03 20:09:58 +00:00
const fields = deriveGroupFields ( masterKeyBuffer ) ;
2021-06-22 14:46:42 +00:00
const groupId = Bytes . toBase64 ( fields . id ) ;
2021-03-03 20:09:58 +00:00
const logId = ` groupv2( ${ groupId } ) ` ;
2021-06-22 14:46:42 +00:00
const masterKey = Bytes . toBase64 ( masterKeyBuffer ) ;
const secretParams = Bytes . toBase64 ( fields . secretParams ) ;
const publicParams = Bytes . toBase64 ( fields . publicParams ) ;
2021-03-03 20:09:58 +00:00
const ourConversationId = window . ConversationController . getOurConversationIdOrThrow ( ) ;
const ourConversation = window . ConversationController . get ( ourConversationId ) ;
if ( ! ourConversation ) {
throw new Error (
` createGroupV2/ ${ logId } : cannot get our own conversation. Cannot create the group `
) ;
}
const membersV2 : Array < GroupV2MemberType > = [
{
conversationId : ourConversationId ,
role : MEMBER_ROLE_ENUM.ADMINISTRATOR ,
joinedAtVersion : 0 ,
} ,
] ;
const pendingMembersV2 : Array < GroupV2PendingMemberType > = [ ] ;
let uploadedAvatar : undefined | UploadedAvatarType ;
await Promise . all ( [
. . . conversationIds . map ( async conversationId = > {
const contact = window . ConversationController . get ( conversationId ) ;
if ( ! contact ) {
assert (
false ,
` createGroupV2/ ${ logId } : missing local contact, skipping `
) ;
return ;
}
if ( ! contact . get ( 'uuid' ) ) {
assert ( false , ` createGroupV2/ ${ logId } : missing UUID; skipping ` ) ;
return ;
}
// Refresh our local data to be sure
if (
! contact . get ( 'capabilities' ) ? . gv2 ||
! contact . get ( 'profileKey' ) ||
! contact . get ( 'profileKeyCredential' )
) {
await contact . getProfiles ( ) ;
}
if ( ! contact . get ( 'capabilities' ) ? . gv2 ) {
assert (
false ,
` createGroupV2/ ${ logId } : member is missing GV2 capability; skipping `
) ;
return ;
}
if ( contact . get ( 'profileKey' ) && contact . get ( 'profileKeyCredential' ) ) {
membersV2 . push ( {
conversationId ,
role : MEMBER_ROLE_ENUM.DEFAULT ,
joinedAtVersion : 0 ,
} ) ;
} else {
pendingMembersV2 . push ( {
addedByUserId : ourConversationId ,
conversationId ,
timestamp : Date.now ( ) ,
role : MEMBER_ROLE_ENUM.DEFAULT ,
} ) ;
}
} ) ,
( async ( ) = > {
if ( ! avatar ) {
return ;
}
uploadedAvatar = await uploadAvatar ( {
data : avatar ,
logId ,
publicParams ,
secretParams ,
} ) ;
} ) ( ) ,
] ) ;
if ( membersV2 . length + pendingMembersV2 . length > getGroupSizeHardLimit ( ) ) {
throw new Error (
` createGroupV2/ ${ logId } : Too many members! Member count: ${ membersV2 . length } , Pending member count: ${ pendingMembersV2 . length } `
) ;
}
const protoAndConversationAttributes = {
name ,
// Core GroupV2 info
revision : 0 ,
publicParams ,
secretParams ,
// GroupV2 state
accessControl : {
attributes : ACCESS_ENUM.MEMBER ,
members : ACCESS_ENUM.MEMBER ,
addFromInviteLink : ACCESS_ENUM.UNSATISFIABLE ,
} ,
membersV2 ,
pendingMembersV2 ,
} ;
const groupProto = await buildGroupProto ( {
id : groupId ,
avatarUrl : uploadedAvatar?.key ,
. . . protoAndConversationAttributes ,
} ) ;
await makeRequestWithTemporalRetry ( {
logId : ` createGroupV2/ ${ logId } ` ,
publicParams ,
secretParams ,
request : ( sender , options ) = > sender . createGroup ( groupProto , options ) ,
} ) ;
let avatarAttribute : ConversationAttributesType [ 'avatar' ] ;
if ( uploadedAvatar ) {
try {
avatarAttribute = {
url : uploadedAvatar.key ,
path : await window . Signal . Migrations . writeNewAttachmentData (
uploadedAvatar . data
) ,
hash : uploadedAvatar.hash ,
} ;
} catch ( err ) {
window . log . warn (
` createGroupV2/ ${ logId } : avatar failed to save to disk. Continuing on `
) ;
}
}
const now = Date . now ( ) ;
const conversation = await window . ConversationController . getOrCreateAndWait (
groupId ,
'group' ,
{
. . . protoAndConversationAttributes ,
active_at : now ,
addedBy : ourConversationId ,
avatar : avatarAttribute ,
groupVersion : 2 ,
masterKey ,
profileSharing : true ,
timestamp : now ,
needsStorageServiceSync : true ,
}
) ;
2021-06-14 21:55:14 +00:00
await conversation . queueJob ( 'storageServiceUploadJob' , ( ) = > {
2021-03-03 20:09:58 +00:00
window . Signal . Services . storageServiceUploadJob ( ) ;
} ) ;
const timestamp = Date . now ( ) ;
2021-05-05 16:39:16 +00:00
const profileKey = await ourProfileKeyService . get ( ) ;
2021-03-03 20:09:58 +00:00
const groupV2Info = conversation . getGroupV2Info ( {
includePendingMembers : true ,
} ) ;
2021-06-22 14:46:42 +00:00
const { ContentHint } = Proto . UnidentifiedSenderMessage . Message ;
2021-06-07 16:39:13 +00:00
const sendOptions = await getSendOptions ( conversation . attributes ) ;
2021-03-03 20:09:58 +00:00
await wrapWithSyncMessageSend ( {
conversation ,
2021-05-25 22:40:04 +00:00
logId : ` sendToGroup/ ${ logId } ` ,
2021-07-15 23:48:09 +00:00
messageIds : [ ] ,
2021-05-25 22:40:04 +00:00
send : async ( ) = >
2021-07-02 18:34:17 +00:00
window . Signal . Util . sendToGroup ( {
groupSendOptions : {
2021-05-25 22:40:04 +00:00
groupV2 : groupV2Info ,
timestamp ,
profileKey ,
} ,
2021-05-28 19:11:19 +00:00
conversation ,
2021-07-15 23:48:09 +00:00
contentHint : ContentHint.RESENDABLE ,
messageId : undefined ,
2021-07-02 18:34:17 +00:00
sendOptions ,
2021-07-15 23:48:09 +00:00
sendType : 'groupChange' ,
2021-07-02 18:34:17 +00:00
} ) ,
2021-07-15 23:48:09 +00:00
sendType : 'groupChange' ,
2021-03-03 20:09:58 +00:00
timestamp ,
} ) ;
const createdTheGroupMessage : MessageAttributesType = {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
sourceUuid : conversation.ourUuid ,
conversationId : conversation.id ,
2021-03-08 15:23:57 +00:00
received_at : window.Signal.Util.incrementMessageCounter ( ) ,
received_at_ms : timestamp ,
2021-03-03 20:09:58 +00:00
sent_at : timestamp ,
groupV2Change : {
from : ourConversationId ,
details : [ { type : 'create' } ] ,
} ,
} ;
await window . Signal . Data . saveMessages ( [ createdTheGroupMessage ] , {
forceSave : true ,
} ) ;
const model = new window . Whisper . Message ( createdTheGroupMessage ) ;
window . MessageController . register ( model . id , model ) ;
conversation . trigger ( 'newmessage' , model ) ;
2021-06-01 20:45:43 +00:00
if ( expireTimer ) {
await conversation . updateExpirationTimer ( expireTimer ) ;
}
2021-03-03 20:09:58 +00:00
return conversation ;
}
2020-11-20 17:30:45 +00:00
// Migrating a group
export async function hasV1GroupBeenMigrated (
conversation : ConversationModel
) : Promise < boolean > {
const logId = conversation . idForLogging ( ) ;
2021-06-07 16:39:13 +00:00
const isGroupV1 = getIsGroupV1 ( conversation . attributes ) ;
2020-11-20 17:30:45 +00:00
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 ) ;
2021-06-22 14:46:42 +00:00
const masterKeyBuffer = new FIXMEU8 (
await deriveMasterKeyFromGroupV1 ( idBuffer )
) ;
2020-11-20 17:30:45 +00:00
const fields = deriveGroupFields ( masterKeyBuffer ) ;
try {
await makeRequestWithTemporalRetry ( {
logId : ` getGroup/ ${ logId } ` ,
2021-06-22 14:46:42 +00:00
publicParams : Bytes.toBase64 ( fields . publicParams ) ,
secretParams : Bytes.toBase64 ( fields . secretParams ) ,
2020-11-20 17:30:45 +00:00
request : ( sender , options ) = > sender . getGroup ( options ) ,
} ) ;
return true ;
} catch ( error ) {
const { code } = error ;
2021-01-12 15:44:44 +00:00
return code !== GROUP_NONEXISTENT_CODE ;
2020-11-20 17:30:45 +00:00
}
}
export async function maybeDeriveGroupV2Id (
conversation : ConversationModel
) : Promise < boolean > {
2021-06-07 16:39:13 +00:00
const isGroupV1 = getIsGroupV1 ( conversation . attributes ) ;
2020-11-20 17:30:45 +00:00
const groupV1Id = conversation . get ( 'groupId' ) ;
const derived = conversation . get ( 'derivedGroupV2Id' ) ;
if ( ! isGroupV1 || ! groupV1Id || derived ) {
return false ;
}
const v1IdBuffer = fromEncodedBinaryToArrayBuffer ( groupV1Id ) ;
2021-06-22 14:46:42 +00:00
const masterKeyBuffer = new FIXMEU8 (
await deriveMasterKeyFromGroupV1 ( v1IdBuffer )
) ;
2020-11-20 17:30:45 +00:00
const fields = deriveGroupFields ( masterKeyBuffer ) ;
2021-06-22 14:46:42 +00:00
const derivedGroupV2Id = Bytes . toBase64 ( fields . id ) ;
2020-11-20 17:30:45 +00:00
conversation . set ( {
derivedGroupV2Id ,
} ) ;
return true ;
}
type MigratePropsType = {
conversation : ConversationModel ;
groupChangeBase64? : string ;
newRevision? : number ;
receivedAt? : number ;
sentAt? : number ;
} ;
export async function isGroupEligibleToMigrate (
conversation : ConversationModel
) : Promise < boolean > {
2021-06-07 16:39:13 +00:00
if ( ! getIsGroupV1 ( conversation . attributes ) ) {
2020-11-20 17:30:45 +00:00
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 ;
}
2020-12-01 16:42:35 +00:00
export async function getGroupMigrationMembers (
conversation : ConversationModel
) : Promise < {
droppedGV2MemberIds : Array < string > ;
membersV2 : Array < GroupV2MemberType > ;
pendingMembersV2 : Array < GroupV2PendingMemberType > ;
previousGroupV1Members : Array < string > ;
} > {
const logId = conversation . idForLogging ( ) ;
2021-06-22 14:46:42 +00:00
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2020-12-01 16:42:35 +00:00
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
if ( ! ourConversationId ) {
throw new Error (
` getGroupMigrationMembers/ ${ logId } : Couldn't fetch our own conversationId! `
) ;
}
let areWeMember = false ;
let areWeInvited = false ;
const previousGroupV1Members = conversation . get ( 'members' ) || [ ] ;
const now = Date . now ( ) ;
const memberLookup : Record < string , boolean > = { } ;
const membersV2 : Array < GroupV2MemberType > = compact (
await Promise . all (
previousGroupV1Members . map ( async e164 = > {
const contact = window . ConversationController . get ( e164 ) ;
if ( ! contact ) {
throw new Error (
` getGroupMigrationMembers/ ${ logId } : membersV2 - missing local contact for ${ e164 } , skipping. `
) ;
}
2021-06-07 16:39:13 +00:00
if ( ! isMe ( contact . attributes ) && window . GV2_MIGRATION_DISABLE_ADD ) {
2021-01-12 15:44:44 +00:00
window . log . warn (
` getGroupMigrationMembers/ ${ logId } : membersV2 - skipping ${ e164 } due to GV2_MIGRATION_DISABLE_ADD flag `
) ;
return null ;
}
2020-12-01 16:42:35 +00:00
if ( ! contact . get ( 'uuid' ) ) {
window . log . warn (
` getGroupMigrationMembers/ ${ logId } : membersV2 - missing uuid for ${ e164 } , skipping. `
) ;
return null ;
}
if ( ! contact . get ( 'profileKey' ) ) {
window . log . warn (
` getGroupMigrationMembers/ ${ 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 (
` getGroupMigrationMembers/ ${ logId } : membersV2 - member ${ e164 } is missing gv2 capability, skipping. `
) ;
return null ;
}
if ( ! capabilities || ! capabilities [ 'gv1-migration' ] ) {
window . log . warn (
` getGroupMigrationMembers/ ${ logId } : membersV2 - member ${ e164 } is missing gv1-migration capability, skipping. `
) ;
return null ;
}
if ( ! contact . get ( 'profileKeyCredential' ) ) {
window . log . warn (
` getGroupMigrationMembers/ ${ 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 < string > = [ ] ;
const pendingMembersV2 : Array < GroupV2PendingMemberType > = compact (
( previousGroupV1Members || [ ] ) . map ( e164 = > {
const contact = window . ConversationController . get ( e164 ) ;
if ( ! contact ) {
throw new Error (
` getGroupMigrationMembers/ ${ 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 ;
}
2021-06-07 16:39:13 +00:00
if ( ! isMe ( contact . attributes ) && window . GV2_MIGRATION_DISABLE_INVITE ) {
2021-01-12 15:44:44 +00:00
window . log . warn (
` getGroupMigrationMembers/ ${ logId } : pendingMembersV2 - skipping ${ e164 } due to GV2_MIGRATION_DISABLE_INVITE flag `
) ;
droppedGV2MemberIds . push ( conversationId ) ;
return null ;
}
2020-12-01 16:42:35 +00:00
if ( ! contact . get ( 'uuid' ) ) {
window . log . warn (
` getGroupMigrationMembers/ ${ logId } : pendingMembersV2 - missing uuid for ${ e164 } , skipping. `
) ;
droppedGV2MemberIds . push ( conversationId ) ;
return null ;
}
const capabilities = contact . get ( 'capabilities' ) ;
if ( ! capabilities || ! capabilities . gv2 ) {
window . log . warn (
` getGroupMigrationMembers/ ${ logId } : pendingMembersV2 - member ${ e164 } is missing gv2 capability, skipping. `
) ;
droppedGV2MemberIds . push ( conversationId ) ;
return null ;
}
if ( ! capabilities || ! capabilities [ 'gv1-migration' ] ) {
window . log . warn (
` getGroupMigrationMembers/ ${ 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 ,
2020-12-01 23:45:39 +00:00
role : MEMBER_ROLE_ENUM.ADMINISTRATOR ,
2020-12-01 16:42:35 +00:00
} ;
} )
) ;
2020-12-01 23:45:39 +00:00
if ( ! areWeMember ) {
throw new Error ( ` getGroupMigrationMembers/ ${ logId } : We are not a member! ` ) ;
}
if ( areWeInvited ) {
throw new Error ( ` getGroupMigrationMembers/ ${ logId } : We are invited! ` ) ;
}
2020-12-01 16:42:35 +00:00
return {
droppedGV2MemberIds ,
membersV2 ,
pendingMembersV2 ,
previousGroupV1Members ,
} ;
}
2020-11-20 17:30:45 +00:00
// 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 < void > {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
try {
2021-06-14 21:55:14 +00:00
await conversation . queueJob ( 'initiateMigrationToGroupV2' , async ( ) = > {
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
2020-11-20 17:30:45 +00:00
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 ) ;
2021-06-22 14:46:42 +00:00
const masterKeyBuffer = new FIXMEU8 (
await deriveMasterKeyFromGroupV1 ( groupV1IdBuffer )
) ;
2020-11-20 17:30:45 +00:00
const fields = deriveGroupFields ( masterKeyBuffer ) ;
2021-06-22 14:46:42 +00:00
const groupId = Bytes . toBase64 ( fields . id ) ;
2020-11-20 17:30:45 +00:00
const logId = ` groupv2( ${ groupId } ) ` ;
window . log . info (
` initiateMigrationToGroupV2/ ${ logId } : Migrating from ${ conversation . idForLogging ( ) } `
) ;
2021-06-22 14:46:42 +00:00
const masterKey = Bytes . toBase64 ( masterKeyBuffer ) ;
const secretParams = Bytes . toBase64 ( fields . secretParams ) ;
const publicParams = Bytes . toBase64 ( fields . publicParams ) ;
2020-11-20 17:30:45 +00:00
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
if ( ! ourConversationId ) {
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : Couldn't fetch our own conversationId! `
) ;
}
2021-03-03 20:09:58 +00:00
const ourConversation = window . ConversationController . get (
ourConversationId
) ;
if ( ! ourConversation ) {
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : cannot get our own conversation. Cannot migrate `
) ;
}
2020-11-20 17:30:45 +00:00
2020-12-01 16:42:35 +00:00
const {
membersV2 ,
pendingMembersV2 ,
droppedGV2MemberIds ,
previousGroupV1Members ,
} = await getGroupMigrationMembers ( conversation ) ;
2020-11-20 17:30:45 +00:00
2021-03-03 20:09:58 +00:00
if (
membersV2 . length + pendingMembersV2 . length >
getGroupSizeHardLimit ( )
) {
2020-12-01 16:42:35 +00:00
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : Too many members! Member count: ${ membersV2 . length } , Pending member count: ${ pendingMembersV2 . length } `
) ;
}
2020-11-20 17:30:45 +00:00
// Note: A few group elements don't need to change here:
// - name
// - expireTimer
2021-03-03 20:09:58 +00:00
let avatarAttribute : ConversationAttributesType [ 'avatar' ] ;
const avatarPath = conversation . attributes . avatar ? . path ;
if ( avatarPath ) {
const { hash , key } = await uploadAvatar ( {
logId ,
publicParams ,
secretParams ,
path : avatarPath ,
} ) ;
avatarAttribute = {
url : key ,
path : avatarPath ,
hash ,
} ;
}
2020-11-20 17:30:45 +00:00
const newAttributes = {
. . . conversation . attributes ,
2021-03-03 20:09:58 +00:00
avatar : avatarAttribute ,
2020-11-20 17:30:45 +00:00
// Core GroupV2 info
revision : 0 ,
groupId ,
groupVersion : 2 ,
masterKey ,
publicParams ,
secretParams ,
// GroupV2 state
accessControl : {
attributes : ACCESS_ENUM.MEMBER ,
members : ACCESS_ENUM.MEMBER ,
2021-01-29 22:16:48 +00:00
addFromInviteLink : ACCESS_ENUM.UNSATISFIABLE ,
2020-11-20 17:30:45 +00:00
} ,
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 ,
} ;
2021-03-03 20:09:58 +00:00
const groupProto = buildGroupProto ( {
. . . newAttributes ,
avatarUrl : avatarAttribute?.url ,
} ) ;
2020-11-20 17:30:45 +00:00
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 < MessageAttributesType > = [ ] ;
groupChangeMessages . push ( {
. . . generateBasicMessage ( ) ,
type : 'group-v1-migration' ,
invitedGV2Members : pendingMembersV2 ,
droppedGV2MemberIds ,
} ) ;
await updateGroup ( {
conversation ,
updates : {
newAttributes ,
groupChangeMessages ,
members : [ ] ,
} ,
} ) ;
2021-06-15 00:09:37 +00:00
if ( window . storage . blocked . isGroupBlocked ( previousGroupV1Id ) ) {
window . storage . blocked . addBlockedGroup ( groupId ) ;
2020-11-20 17:30:45 +00:00
}
// Save these most recent updates to conversation
updateConversation ( conversation . attributes ) ;
} ) ;
} catch ( error ) {
const logId = conversation . idForLogging ( ) ;
2021-06-07 16:39:13 +00:00
if ( ! getIsGroupV1 ( conversation . attributes ) ) {
2020-11-20 17:30:45 +00:00
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 ( ) ;
2021-05-05 16:39:16 +00:00
const ourProfileKey :
| ArrayBuffer
| undefined = await ourProfileKeyService . get ( ) ;
2021-06-22 14:46:42 +00:00
const { ContentHint } = Proto . UnidentifiedSenderMessage . Message ;
2021-06-07 16:39:13 +00:00
const sendOptions = await getSendOptions ( conversation . attributes ) ;
2021-05-28 19:11:19 +00:00
2020-11-20 17:30:45 +00:00
await wrapWithSyncMessageSend ( {
conversation ,
2021-05-25 22:40:04 +00:00
logId : ` sendToGroup/ ${ logId } ` ,
2021-07-15 23:48:09 +00:00
messageIds : [ ] ,
2021-05-25 22:40:04 +00:00
send : async ( ) = >
2020-11-20 17:30:45 +00:00
// Minimal message to notify group members about migration
2021-07-02 18:34:17 +00:00
window . Signal . Util . sendToGroup ( {
groupSendOptions : {
2021-05-25 22:40:04 +00:00
groupV2 : conversation.getGroupV2Info ( {
includePendingMembers : true ,
} ) ,
timestamp ,
profileKey : ourProfileKey ,
} ,
2021-05-28 19:11:19 +00:00
conversation ,
2021-07-15 23:48:09 +00:00
contentHint : ContentHint.RESENDABLE ,
messageId : undefined ,
2021-07-02 18:34:17 +00:00
sendOptions ,
2021-07-15 23:48:09 +00:00
sendType : 'groupChange' ,
2021-07-02 18:34:17 +00:00
} ) ,
2021-07-15 23:48:09 +00:00
sendType : 'groupChange' ,
2020-11-20 17:30:45 +00:00
timestamp ,
} ) ;
}
2020-12-09 22:02:50 +00:00
export async function wrapWithSyncMessageSend ( {
2020-11-20 17:30:45 +00:00
conversation ,
logId ,
2021-07-15 23:48:09 +00:00
messageIds ,
2020-11-20 17:30:45 +00:00
send ,
2021-07-15 23:48:09 +00:00
sendType ,
2020-11-20 17:30:45 +00:00
timestamp ,
} : {
conversation : ConversationModel ;
logId : string ;
2021-07-15 23:48:09 +00:00
messageIds : Array < string > ;
send : ( sender : MessageSender ) = > Promise < CallbackResultType > ;
sendType : SendTypesType ;
2020-11-20 17:30:45 +00:00
timestamp : number ;
2020-12-09 22:02:50 +00:00
} ) : Promise < void > {
2020-11-20 17:30:45 +00:00
const sender = window . textsecure . messaging ;
if ( ! sender ) {
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : textsecure.messaging is not available! `
) ;
}
let response : CallbackResultType | undefined ;
try {
2021-07-15 23:48:09 +00:00
response = await handleMessageSend ( send ( sender ) , { messageIds , sendType } ) ;
2020-11-20 17:30:45 +00:00
} 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! `
) ;
}
2021-07-15 23:48:09 +00:00
if ( window . ConversationController . areWePrimaryDevice ( ) ) {
window . log . warn (
` wrapWithSyncMessageSend/ ${ logId } : We are primary device; not sync message `
) ;
return ;
}
const options = await getSendOptions ( ourConversation . attributes ) ;
await handleMessageSend (
sender . sendSyncMessage ( {
destination : ourConversation.get ( 'e164' ) ,
destinationUuid : ourConversation.get ( 'uuid' ) ,
encodedDataMessage : dataMessage ,
expirationStartTimestamp : null ,
options ,
timestamp ,
} ) ,
{ messageIds , sendType }
) ;
2020-11-20 17:30:45 +00:00
}
export async function waitThenRespondToGroupV2Migration (
options : MigratePropsType
) : Promise < void > {
// 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 ;
2021-06-14 21:55:14 +00:00
await conversation . queueJob ( 'waitThenRespondToGroupV2Migration' , async ( ) = > {
2020-11-20 17:30:45 +00:00
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
) ;
}
} ) ;
}
2021-01-29 22:16:48 +00:00
export function buildMigrationBubble (
previousGroupV1MembersIds : Array < string > ,
newAttributes : ConversationAttributesType
) : MessageAttributesType {
const ourConversationId = window . ConversationController . getOurConversationIdOrThrow ( ) ;
// Assemble items to commemorate this event for the timeline..
const combinedConversationIds : Array < string > = [
. . . ( newAttributes . membersV2 || [ ] ) . map ( item = > item . conversationId ) ,
. . . ( newAttributes . pendingMembersV2 || [ ] ) . map ( item = > item . conversationId ) ,
] ;
const droppedMemberIds : Array < string > = difference (
previousGroupV1MembersIds ,
combinedConversationIds
) . filter ( id = > id && id !== ourConversationId ) ;
const invitedMembers = ( newAttributes . pendingMembersV2 || [ ] ) . filter (
item = > item . conversationId !== ourConversationId
) ;
const areWeInvited = ( newAttributes . pendingMembersV2 || [ ] ) . some (
item = > item . conversationId === ourConversationId
) ;
return {
. . . generateBasicMessage ( ) ,
type : 'group-v1-migration' ,
groupMigration : {
areWeInvited ,
invitedMembers ,
droppedMemberIds ,
} ,
} ;
}
export async function joinGroupV2ViaLinkAndMigrate ( {
approvalRequired ,
conversation ,
inviteLinkPassword ,
revision ,
} : {
approvalRequired : boolean ;
conversation : ConversationModel ;
inviteLinkPassword : string ;
revision : number ;
} ) : Promise < void > {
2021-06-07 16:39:13 +00:00
const isGroupV1 = getIsGroupV1 ( conversation . attributes ) ;
2021-01-29 22:16:48 +00:00
const previousGroupV1Id = conversation . get ( 'groupId' ) ;
if ( ! isGroupV1 || ! previousGroupV1Id ) {
throw new Error (
` joinGroupV2ViaLinkAndMigrate: Conversation is not GroupV1! ${ conversation . idForLogging ( ) } `
) ;
}
// Derive GroupV2 fields
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer ( previousGroupV1Id ) ;
2021-06-22 14:46:42 +00:00
const masterKeyBuffer = new FIXMEU8 (
await deriveMasterKeyFromGroupV1 ( groupV1IdBuffer )
) ;
2021-01-29 22:16:48 +00:00
const fields = deriveGroupFields ( masterKeyBuffer ) ;
2021-06-22 14:46:42 +00:00
const groupId = Bytes . toBase64 ( fields . id ) ;
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( groupId ) ;
window . log . info (
` joinGroupV2ViaLinkAndMigrate/ ${ logId } : Migrating from ${ conversation . idForLogging ( ) } `
) ;
2021-06-22 14:46:42 +00:00
const masterKey = Bytes . toBase64 ( masterKeyBuffer ) ;
const secretParams = Bytes . toBase64 ( fields . secretParams ) ;
const publicParams = Bytes . toBase64 ( fields . publicParams ) ;
2021-01-29 22:16:48 +00:00
// A mini-migration, which will not show dropped/invited members
const newAttributes = {
. . . conversation . attributes ,
// Core GroupV2 info
revision ,
groupId ,
groupVersion : 2 ,
masterKey ,
publicParams ,
secretParams ,
groupInviteLinkPassword : inviteLinkPassword ,
left : true ,
// Capture previous GroupV1 data for future use
previousGroupV1Id : conversation.get ( 'groupId' ) ,
previousGroupV1Members : conversation.get ( 'members' ) ,
// Clear storage ID, since we need to start over on the storage service
storageID : undefined ,
// Clear obsolete data
derivedGroupV2Id : undefined ,
members : undefined ,
} ;
2021-04-05 22:18:19 +00:00
const groupChangeMessages : Array < MessageAttributesType > = [
2021-01-29 22:16:48 +00:00
{
. . . generateBasicMessage ( ) ,
type : 'group-v1-migration' ,
groupMigration : {
areWeInvited : false ,
invitedMembers : [ ] ,
droppedMemberIds : [ ] ,
} ,
} ,
] ;
await updateGroup ( {
conversation ,
updates : {
newAttributes ,
groupChangeMessages ,
members : [ ] ,
} ,
} ) ;
// Now things are set up, so we can go through normal channels
await conversation . joinGroupV2ViaLink ( {
inviteLinkPassword ,
approvalRequired ,
} ) ;
}
2020-11-20 17:30:45 +00:00
// 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 < void > {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
2021-06-07 16:39:13 +00:00
const isGroupV1 = getIsGroupV1 ( conversation . attributes ) ;
2020-11-20 17:30:45 +00:00
const previousGroupV1Id = conversation . get ( 'groupId' ) ;
if ( ! isGroupV1 || ! previousGroupV1Id ) {
throw new Error (
` respondToGroupV2Migration: Conversation is not GroupV1! ${ conversation . idForLogging ( ) } `
) ;
}
2021-01-29 22:16:48 +00:00
const ourConversationId = window . ConversationController . getOurConversationIdOrThrow ( ) ;
2020-11-20 17:30:45 +00:00
const wereWePreviouslyAMember =
! conversation . get ( 'left' ) &&
ourConversationId &&
conversation . hasMember ( ourConversationId ) ;
// Derive GroupV2 fields
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer ( previousGroupV1Id ) ;
2021-06-22 14:46:42 +00:00
const masterKeyBuffer = new FIXMEU8 (
await deriveMasterKeyFromGroupV1 ( groupV1IdBuffer )
) ;
2020-11-20 17:30:45 +00:00
const fields = deriveGroupFields ( masterKeyBuffer ) ;
2021-06-22 14:46:42 +00:00
const groupId = Bytes . toBase64 ( fields . id ) ;
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( groupId ) ;
2020-11-20 17:30:45 +00:00
window . log . info (
` respondToGroupV2Migration/ ${ logId } : Migrating from ${ conversation . idForLogging ( ) } `
) ;
2021-06-22 14:46:42 +00:00
const masterKey = Bytes . toBase64 ( masterKeyBuffer ) ;
const secretParams = Bytes . toBase64 ( fields . secretParams ) ;
const publicParams = Bytes . toBase64 ( fields . publicParams ) ;
2020-11-20 17:30:45 +00:00
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 ,
} ;
2021-06-22 14:46:42 +00:00
let firstGroupState : Proto.IGroup | null | undefined ;
2020-11-20 17:30:45 +00:00
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 `
) ;
2021-01-12 15:44:44 +00:00
try {
firstGroupState = await makeRequestWithTemporalRetry ( {
logId : ` getGroup/ ${ logId } ` ,
publicParams ,
secretParams ,
request : ( sender , options ) = > sender . getGroup ( options ) ,
} ) ;
} catch ( secondError ) {
if ( secondError . code === GROUP_ACCESS_DENIED_CODE ) {
window . log . info (
` respondToGroupV2Migration/ ${ logId } : Failed to access state endpoint; user is no longer part of group `
) ;
// We don't want to add another event to the timeline
if ( wereWePreviouslyAMember ) {
const ourNumber = window . textsecure . storage . user . getNumber ( ) ;
await updateGroup ( {
conversation ,
receivedAt ,
sentAt ,
updates : {
newAttributes : {
. . . conversation . attributes ,
left : true ,
members : ( conversation . get ( 'members' ) || [ ] ) . filter (
item = > item !== ourConversationId && item !== ourNumber
) ,
} ,
groupChangeMessages : [
{
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
details : [
{
type : 'member-remove' as const ,
conversationId : ourConversationId ,
} ,
] ,
} ,
} ,
] ,
members : [ ] ,
} ,
} ) ;
return ;
}
}
throw secondError ;
}
2020-11-20 17:30:45 +00:00
} else {
throw error ;
}
}
if ( ! firstGroupState ) {
throw new Error (
` respondToGroupV2Migration/ ${ logId } : Couldn't get a first group state! `
) ;
}
const groupState = decryptGroupState (
firstGroupState ,
attributes . secretParams ,
logId
) ;
2021-04-07 22:45:31 +00:00
const { newAttributes , newProfileKeys } = await applyGroupState ( {
2020-11-20 17:30:45 +00:00
group : attributes ,
groupState ,
} ) ;
2021-01-29 22:16:48 +00:00
// Generate notifications into the timeline
const groupChangeMessages : Array < MessageAttributesType > = [ ] ;
groupChangeMessages . push (
buildMigrationBubble ( previousGroupV1MembersIds , newAttributes )
2020-11-20 17:30:45 +00:00
) ;
2020-12-01 23:45:39 +00:00
const areWeInvited = ( newAttributes . pendingMembersV2 || [ ] ) . some (
item = > item . conversationId === ourConversationId
) ;
const areWeMember = ( newAttributes . membersV2 || [ ] ) . some (
item = > item . conversationId === ourConversationId
) ;
2020-11-20 17:30:45 +00:00
if ( ! areWeInvited && ! areWeMember ) {
2020-12-01 23:45:39 +00:00
// Add a message to the timeline saying the user was removed. This shouldn't happen.
2020-11-20 17:30:45 +00:00
groupChangeMessages . push ( {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
details : [
{
type : 'member-remove' 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 ,
2021-04-07 22:45:31 +00:00
members : profileKeysToMembers ( newProfileKeys ) ,
2020-11-20 17:30:45 +00:00
} ,
} ) ;
2021-06-15 00:09:37 +00:00
if ( window . storage . blocked . isGroupBlocked ( previousGroupV1Id ) ) {
window . storage . blocked . addBlockedGroup ( groupId ) ;
2020-11-20 17:30:45 +00:00
}
// 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 ,
} ) ;
}
2020-09-09 02:25:05 +00:00
// Fetching and applying group changes
type MaybeUpdatePropsType = {
2020-09-24 20:57:54 +00:00
conversation : ConversationModel ;
2020-09-09 02:25:05 +00:00
groupChangeBase64? : string ;
newRevision? : number ;
2020-09-10 21:04:45 +00:00
receivedAt? : number ;
sentAt? : number ;
2020-09-09 02:25:05 +00:00
dropInitialJoinMessage? : boolean ;
} ;
2021-05-07 20:07:24 +00:00
const FIVE_MINUTES = 1000 * 60 * 5 ;
2020-09-11 19:37:01 +00:00
export async function waitThenMaybeUpdateGroup (
2021-04-20 23:16:49 +00:00
options : MaybeUpdatePropsType ,
{ viaSync = false } = { }
2020-09-11 19:37:01 +00:00
) : Promise < void > {
2021-07-16 17:43:33 +00:00
const { conversation } = options ;
if ( conversation . isBlocked ( ) ) {
window . log . info (
` waitThenMaybeUpdateGroup: Group ${ conversation . idForLogging ( ) } is blocked, returning early `
) ;
return ;
}
2020-09-09 02:25:05 +00:00
// First wait to process all incoming messages on the websocket
await window . waitForEmptyEventQueue ( ) ;
2021-07-16 17:43:33 +00:00
// Then make sure we haven't fetched this group too recently
2021-05-07 20:07:24 +00:00
const { lastSuccessfulGroupFetch = 0 } = conversation ;
if ( isMoreRecentThan ( lastSuccessfulGroupFetch , FIVE_MINUTES ) ) {
const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date . now ( ) ;
window . log . info (
` waitThenMaybeUpdateGroup/ ${ conversation . idForLogging ( ) } : group update ` +
` was fetched recently, skipping for ${ waitTime } ms `
) ;
return ;
}
2021-07-16 17:43:33 +00:00
// Then wait to process all outstanding messages for this conversation
2021-06-14 21:55:14 +00:00
await conversation . queueJob ( 'waitThenMaybeUpdateGroup' , async ( ) = > {
2020-09-09 02:25:05 +00:00
try {
// And finally try to update the group
2021-04-20 23:16:49 +00:00
await maybeUpdateGroup ( options , { viaSync } ) ;
2021-05-07 20:07:24 +00:00
conversation . lastSuccessfulGroupFetch = Date . now ( ) ;
2020-09-09 02:25:05 +00:00
} catch ( error ) {
window . log . error (
` waitThenMaybeUpdateGroup/ ${ conversation . idForLogging ( ) } : maybeUpdateGroup failure: ` ,
error && error . stack ? error.stack : error
) ;
}
} ) ;
}
2021-04-20 23:16:49 +00:00
export async function maybeUpdateGroup (
{
conversation ,
dropInitialJoinMessage ,
groupChangeBase64 ,
newRevision ,
receivedAt ,
sentAt ,
} : MaybeUpdatePropsType ,
{ viaSync = false } = { }
) : Promise < void > {
2020-09-09 02:25:05 +00:00
const logId = conversation . idForLogging ( ) ;
try {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
2020-10-06 17:06:34 +00:00
const updates = await getGroupUpdates ( {
2020-09-09 02:25:05 +00:00
group : conversation.attributes ,
serverPublicParamsBase64 : window.getServerPublicParams ( ) ,
newRevision ,
groupChangeBase64 ,
dropInitialJoinMessage ,
} ) ;
2021-04-20 23:16:49 +00:00
await updateGroup (
{ conversation , receivedAt , sentAt , updates } ,
{ viaSync }
) ;
2020-09-09 02:25:05 +00:00
} catch ( error ) {
window . log . error (
` maybeUpdateGroup/ ${ logId } : Failed to update group: ` ,
error && error . stack ? error.stack : error
) ;
throw error ;
}
}
2021-04-20 23:16:49 +00:00
async function updateGroup (
{
conversation ,
receivedAt ,
sentAt ,
updates ,
} : {
conversation : ConversationModel ;
receivedAt? : number ;
sentAt? : number ;
updates : UpdatesResultType ;
} ,
{ viaSync = false } = { }
) : Promise < void > {
2020-10-06 17:06:34 +00:00
const { newAttributes , groupChangeMessages , members } = updates ;
const startingRevision = conversation . get ( 'revision' ) ;
const endingRevision = newAttributes . revision ;
const isInitialDataFetch =
! isNumber ( startingRevision ) && isNumber ( endingRevision ) ;
2021-01-29 22:16:48 +00:00
const isInGroup = ! updates . newAttributes . left ;
const justJoinedGroup = conversation . get ( 'left' ) && isInGroup ;
2020-10-06 17:06:34 +00:00
// Ensure that all generated messages are ordered properly.
// Before the provided timestamp so update messages appear before the
// initiating message, or after now().
2021-03-17 22:48:58 +00:00
const finalReceivedAt =
receivedAt || window . Signal . Util . incrementMessageCounter ( ) ;
const initialSentAt = sentAt || Date . now ( ) ;
2020-11-20 17:30:45 +00:00
// 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 ;
2020-10-06 17:06:34 +00:00
2021-04-20 23:16:49 +00:00
// We force this conversation into the left pane if this is the first time we've
// fetched data about it, and we were able to fetch its name. Nobody likes to see
// Unknown Group in the left pane.
let activeAt = null ;
if ( viaSync ) {
activeAt = null ;
} else if ( ( isInitialDataFetch || justJoinedGroup ) && newAttributes . name ) {
activeAt = initialSentAt ;
} else {
activeAt = newAttributes . active_at ;
}
2020-10-06 17:06:34 +00:00
conversation . set ( {
. . . newAttributes ,
2021-04-20 23:16:49 +00:00
active_at : activeAt ,
2021-01-29 22:16:48 +00:00
temporaryMemberCount : isInGroup
? undefined
: newAttributes . temporaryMemberCount ,
2020-10-06 17:06:34 +00:00
} ) ;
2020-11-20 17:30:45 +00:00
if ( idChanged ) {
conversation . trigger ( 'idUpdated' , conversation , 'groupId' , previousId ) ;
}
2020-10-06 17:06:34 +00:00
// Save all synthetic messages describing group changes
2021-03-17 22:48:58 +00:00
let syntheticSentAt = initialSentAt - ( groupChangeMessages . length + 1 ) ;
2020-10-06 17:06:34 +00:00
const changeMessagesToSave = groupChangeMessages . map ( changeMessage = > {
2020-11-20 17:30:45 +00:00
// 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 ;
2020-10-06 17:06:34 +00:00
return {
. . . changeMessage ,
conversationId : conversation.id ,
2021-03-17 22:48:58 +00:00
received_at : finalReceivedAt ,
received_at_ms : syntheticSentAt ,
2020-11-20 17:30:45 +00:00
sent_at : syntheticSentAt ,
2020-10-06 17:06:34 +00:00
} ;
} ) ;
if ( changeMessagesToSave . length > 0 ) {
await window . Signal . Data . saveMessages ( changeMessagesToSave , {
forceSave : true ,
} ) ;
changeMessagesToSave . forEach ( changeMessage = > {
const model = new window . Whisper . Message ( changeMessage ) ;
window . MessageController . register ( model . id , model ) ;
conversation . trigger ( 'newmessage' , model ) ;
} ) ;
}
// Capture profile key for each member in the group, if we don't have it yet
members . forEach ( member = > {
const contact = window . ConversationController . get ( member . uuid ) ;
if ( member . profileKey && contact && ! contact . get ( 'profileKey' ) ) {
contact . setProfileKey ( member . profileKey ) ;
}
} ) ;
// No need for convo.updateLastMessage(), 'newmessage' handler does that
}
2020-09-09 02:25:05 +00:00
async function getGroupUpdates ( {
dropInitialJoinMessage ,
group ,
serverPublicParamsBase64 ,
newRevision ,
groupChangeBase64 ,
} : {
dropInitialJoinMessage? : boolean ;
group : ConversationAttributesType ;
groupChangeBase64? : string ;
newRevision? : number ;
serverPublicParamsBase64 : string ;
} ) : Promise < UpdatesResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
window . log . info ( ` getGroupUpdates/ ${ logId } : Starting... ` ) ;
const currentRevision = group . revision ;
const isFirstFetch = ! isNumber ( group . revision ) ;
2021-01-29 22:16:48 +00:00
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
2020-09-09 02:25:05 +00:00
2020-10-06 17:06:34 +00:00
const isInitialCreationMessage = isFirstFetch && newRevision === 0 ;
2021-01-29 22:16:48 +00:00
const weAreAwaitingApproval = ( group . pendingAdminApprovalV2 || [ ] ) . find (
item = > item . conversationId === ourConversationId
) ;
2020-10-06 17:06:34 +00:00
const isOneVersionUp =
isNumber ( currentRevision ) &&
isNumber ( newRevision ) &&
newRevision === currentRevision + 1 ;
2020-09-09 02:25:05 +00:00
if (
2020-12-18 19:27:43 +00:00
window . GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
2020-09-09 02:25:05 +00:00
groupChangeBase64 &&
2020-10-06 17:06:34 +00:00
isNumber ( newRevision ) &&
2021-01-29 22:16:48 +00:00
( isInitialCreationMessage || weAreAwaitingApproval || isOneVersionUp )
2020-09-09 02:25:05 +00:00
) {
window . log . info ( ` getGroupUpdates/ ${ logId } : Processing just one change ` ) ;
2021-06-22 14:46:42 +00:00
const groupChangeBuffer = Bytes . fromBase64 ( groupChangeBase64 ) ;
const groupChange = Proto . GroupChange . decode ( groupChangeBuffer ) ;
2020-10-21 00:39:13 +00:00
const isChangeSupported =
! isNumber ( groupChange . changeEpoch ) ||
groupChange . changeEpoch <= SUPPORTED_CHANGE_EPOCH ;
if ( isChangeSupported ) {
2021-01-29 22:16:48 +00:00
return updateGroupViaSingleChange ( {
group ,
newRevision ,
groupChange ,
serverPublicParamsBase64 ,
} ) ;
2020-10-21 00:39:13 +00:00
}
window . log . info (
` getGroupUpdates/ ${ logId } : Failing over; group change unsupported `
) ;
2020-09-09 02:25:05 +00:00
}
2020-12-18 19:27:43 +00:00
if ( isNumber ( newRevision ) && window . GV2_ENABLE_CHANGE_PROCESSING ) {
2020-09-09 02:25:05 +00:00
try {
const result = await updateGroupViaLogs ( {
group ,
serverPublicParamsBase64 ,
newRevision ,
} ) ;
return result ;
} catch ( error ) {
if ( error . code === TEMPORAL_AUTH_REJECTED_CODE ) {
// We will fail over to the updateGroupViaState call below
window . log . info (
` getGroupUpdates/ ${ logId } : Temporal credential failure, now fetching full group state `
) ;
} else if ( error . code === GROUP_ACCESS_DENIED_CODE ) {
// We will fail over to the updateGroupViaState call below
window . log . info (
` getGroupUpdates/ ${ logId } : Log access denied, now fetching full group state `
) ;
} else {
throw error ;
}
}
}
2020-12-18 19:27:43 +00:00
if ( window . GV2_ENABLE_STATE_PROCESSING ) {
return updateGroupViaState ( {
dropInitialJoinMessage ,
group ,
serverPublicParamsBase64 ,
} ) ;
}
window . log . warn (
` getGroupUpdates/ ${ logId } : No processing was legal! Returning empty changeset. `
) ;
return {
newAttributes : group ,
groupChangeMessages : [ ] ,
members : [ ] ,
} ;
2020-09-09 02:25:05 +00:00
}
async function updateGroupViaState ( {
dropInitialJoinMessage ,
group ,
serverPublicParamsBase64 ,
} : {
dropInitialJoinMessage? : boolean ;
group : ConversationAttributesType ;
serverPublicParamsBase64 : string ;
} ) : Promise < UpdatesResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
const data = window . storage . get ( GROUP_CREDENTIALS_KEY ) ;
if ( ! data ) {
throw new Error ( 'updateGroupViaState: No group credentials!' ) ;
}
const groupCredentials = getCredentialsForToday ( data ) ;
const stateOptions = {
dropInitialJoinMessage ,
group ,
serverPublicParamsBase64 ,
authCredentialBase64 : groupCredentials.today.credential ,
} ;
try {
window . log . info (
` updateGroupViaState/ ${ logId } : Getting full group state... `
) ;
// We await this here so our try/catch below takes effect
const result = await getCurrentGroupState ( stateOptions ) ;
return result ;
} catch ( error ) {
if ( error . code === GROUP_ACCESS_DENIED_CODE ) {
return generateLeftGroupChanges ( group ) ;
}
if ( error . code === TEMPORAL_AUTH_REJECTED_CODE ) {
window . log . info (
` updateGroupViaState/ ${ logId } : Credential for today failed, failing over to tomorrow... `
) ;
try {
const result = await getCurrentGroupState ( {
. . . stateOptions ,
authCredentialBase64 : groupCredentials.tomorrow.credential ,
} ) ;
return result ;
2020-09-11 19:37:01 +00:00
} catch ( subError ) {
if ( subError . code === GROUP_ACCESS_DENIED_CODE ) {
2020-09-09 02:25:05 +00:00
return generateLeftGroupChanges ( group ) ;
}
}
}
throw error ;
}
}
2021-01-29 22:16:48 +00:00
async function updateGroupViaSingleChange ( {
group ,
groupChange ,
newRevision ,
serverPublicParamsBase64 ,
} : {
group : ConversationAttributesType ;
2021-06-22 14:46:42 +00:00
groupChange : Proto.IGroupChange ;
2021-01-29 22:16:48 +00:00
newRevision : number ;
serverPublicParamsBase64 : string ;
} ) : Promise < UpdatesResultType > {
const wasInGroup = ! group . left ;
const result : UpdatesResultType = await integrateGroupChange ( {
group ,
groupChange ,
newRevision ,
} ) ;
const nowInGroup = ! result . newAttributes . left ;
// If we were just added to the group (for example, via a join link), we go fetch the
// entire group state to make sure we're up to date.
if ( ! wasInGroup && nowInGroup ) {
const { newAttributes , members } = await updateGroupViaState ( {
group : result.newAttributes ,
serverPublicParamsBase64 ,
} ) ;
// We discard any change events that come out of this full group fetch, but we do
// keep the final group attributes generated, as well as any new members.
return {
. . . result ,
members : [ . . . result . members , . . . members ] ,
newAttributes ,
} ;
}
return result ;
}
2020-09-09 02:25:05 +00:00
async function updateGroupViaLogs ( {
group ,
serverPublicParamsBase64 ,
newRevision ,
} : {
group : ConversationAttributesType ;
newRevision : number ;
serverPublicParamsBase64 : string ;
} ) : Promise < UpdatesResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
const data = window . storage . get ( GROUP_CREDENTIALS_KEY ) ;
if ( ! data ) {
throw new Error ( 'getGroupUpdates: No group credentials!' ) ;
}
const groupCredentials = getCredentialsForToday ( data ) ;
const deltaOptions = {
group ,
newRevision ,
serverPublicParamsBase64 ,
authCredentialBase64 : groupCredentials.today.credential ,
} ;
try {
window . log . info (
` updateGroupViaLogs/ ${ logId } : Getting group delta from ${ group . revision } to ${ newRevision } for group groupv2( ${ group . groupId } )... `
) ;
const result = await getGroupDelta ( deltaOptions ) ;
return result ;
} catch ( error ) {
if ( error . code === TEMPORAL_AUTH_REJECTED_CODE ) {
window . log . info (
` updateGroupViaLogs/ ${ logId } : Credential for today failed, failing over to tomorrow... `
) ;
return getGroupDelta ( {
. . . deltaOptions ,
authCredentialBase64 : groupCredentials.tomorrow.credential ,
} ) ;
}
2020-09-11 19:37:01 +00:00
throw error ;
2020-09-09 02:25:05 +00:00
}
}
function generateBasicMessage() {
return {
id : getGuid ( ) ,
schemaVersion : MAX_MESSAGE_SCHEMA ,
2020-09-24 20:57:54 +00:00
// this is missing most properties to fulfill this type
} as MessageAttributesType ;
2020-09-09 02:25:05 +00:00
}
2021-01-29 22:16:48 +00:00
async function generateLeftGroupChanges (
2020-09-09 02:25:05 +00:00
group : ConversationAttributesType
2021-01-29 22:16:48 +00:00
) : Promise < UpdatesResultType > {
const logId = idForLogging ( group . groupId ) ;
2020-11-20 17:30:45 +00:00
window . log . info ( ` generateLeftGroupChanges/ ${ logId } : Starting... ` ) ;
2020-09-09 02:25:05 +00:00
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
if ( ! ourConversationId ) {
throw new Error (
'generateLeftGroupChanges: We do not have a conversationId!'
) ;
}
2021-01-29 22:16:48 +00:00
const { masterKey , groupInviteLinkPassword } = group ;
let { revision } = group ;
try {
if ( masterKey && groupInviteLinkPassword ) {
window . log . info (
` generateLeftGroupChanges/ ${ logId } : Have invite link. Attempting to fetch latest revision with it. `
) ;
const preJoinInfo = await getPreJoinGroupInfo (
groupInviteLinkPassword ,
masterKey
) ;
revision = preJoinInfo . version ;
}
} catch ( error ) {
window . log . warn (
'generateLeftGroupChanges: Failed to fetch latest revision via group link. Code:' ,
error . code
) ;
}
2020-09-09 02:25:05 +00:00
const existingMembers = group . membersV2 || [ ] ;
const newAttributes : ConversationAttributesType = {
. . . group ,
membersV2 : existingMembers.filter (
member = > member . conversationId !== ourConversationId
) ,
left : true ,
2021-01-29 22:16:48 +00:00
revision ,
2020-09-09 02:25:05 +00:00
} ;
const isNewlyRemoved =
existingMembers . length > ( newAttributes . membersV2 || [ ] ) . length ;
2021-04-05 22:18:19 +00:00
const youWereRemovedMessage : MessageAttributesType = {
2020-09-09 02:25:05 +00:00
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
details : [
{
type : 'member-remove' as const ,
conversationId : ourConversationId ,
} ,
] ,
} ,
} ;
return {
newAttributes ,
groupChangeMessages : isNewlyRemoved ? [ youWereRemovedMessage ] : [ ] ,
members : [ ] ,
} ;
}
function getGroupCredentials ( {
authCredentialBase64 ,
groupPublicParamsBase64 ,
groupSecretParamsBase64 ,
serverPublicParamsBase64 ,
} : {
authCredentialBase64 : string ;
groupPublicParamsBase64 : string ;
groupSecretParamsBase64 : string ;
serverPublicParamsBase64 : string ;
} ) : GroupCredentialsType {
const authOperations = getClientZkAuthOperations ( serverPublicParamsBase64 ) ;
const presentation = getAuthCredentialPresentation (
authOperations ,
authCredentialBase64 ,
groupSecretParamsBase64
) ;
return {
2021-06-22 14:46:42 +00:00
groupPublicParamsHex : Bytes.toHex (
Bytes . fromBase64 ( groupPublicParamsBase64 )
2020-09-09 02:25:05 +00:00
) ,
2021-06-22 14:46:42 +00:00
authCredentialPresentationHex : Bytes.toHex ( presentation ) ,
2020-09-09 02:25:05 +00:00
} ;
}
async function getGroupDelta ( {
group ,
newRevision ,
serverPublicParamsBase64 ,
authCredentialBase64 ,
} : {
group : ConversationAttributesType ;
newRevision : number ;
serverPublicParamsBase64 : string ;
authCredentialBase64 : string ;
} ) : Promise < UpdatesResultType > {
const sender = window . textsecure . messaging ;
if ( ! sender ) {
throw new Error ( 'getGroupDelta: textsecure.messaging is not available!' ) ;
}
if ( ! group . publicParams ) {
throw new Error ( 'getGroupDelta: group was missing publicParams!' ) ;
}
if ( ! group . secretParams ) {
throw new Error ( 'getGroupDelta: group was missing secretParams!' ) ;
}
const options = getGroupCredentials ( {
authCredentialBase64 ,
groupPublicParamsBase64 : group.publicParams ,
groupSecretParamsBase64 : group.secretParams ,
serverPublicParamsBase64 ,
} ) ;
const currentRevision = group . revision ;
let revisionToFetch = isNumber ( currentRevision ) ? currentRevision + 1 : 0 ;
let response ;
2021-06-22 14:46:42 +00:00
const changes : Array < Proto.IGroupChanges > = [ ] ;
2020-09-09 02:25:05 +00:00
do {
2020-09-11 19:37:01 +00:00
// eslint-disable-next-line no-await-in-loop
2020-09-09 02:25:05 +00:00
response = await sender . getGroupLog ( revisionToFetch , options ) ;
changes . push ( response . changes ) ;
if ( response . end ) {
revisionToFetch = response . end + 1 ;
}
} while ( response . end && response . end < newRevision ) ;
// Would be nice to cache the unused groupChanges here, to reduce server roundtrips
return integrateGroupChanges ( {
changes ,
group ,
newRevision ,
} ) ;
}
async function integrateGroupChanges ( {
group ,
newRevision ,
changes ,
} : {
group : ConversationAttributesType ;
newRevision : number ;
2021-06-22 14:46:42 +00:00
changes : Array < Proto.IGroupChanges > ;
2020-09-09 02:25:05 +00:00
} ) : Promise < UpdatesResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
let attributes = group ;
const finalMessages : Array < Array < MessageAttributesType > > = [ ] ;
const finalMembers : Array < Array < MemberType > > = [ ] ;
const imax = changes . length ;
for ( let i = 0 ; i < imax ; i += 1 ) {
const { groupChanges } = changes [ i ] ;
if ( ! groupChanges ) {
continue ;
}
const jmax = groupChanges . length ;
for ( let j = 0 ; j < jmax ; j += 1 ) {
const changeState = groupChanges [ j ] ;
2020-10-06 17:06:34 +00:00
const { groupChange , groupState } = changeState ;
2020-09-09 02:25:05 +00:00
2021-02-08 21:55:21 +00:00
if ( ! groupChange && ! groupState ) {
2020-10-06 17:06:34 +00:00
window . log . warn (
'integrateGroupChanges: item had neither groupState nor groupChange. Skipping.'
) ;
2020-09-09 02:25:05 +00:00
continue ;
}
try {
const {
newAttributes ,
groupChangeMessages ,
members ,
2020-09-11 19:37:01 +00:00
// eslint-disable-next-line no-await-in-loop
2020-09-09 02:25:05 +00:00
} = await integrateGroupChange ( {
group : attributes ,
newRevision ,
2021-06-22 14:46:42 +00:00
groupChange : dropNull ( groupChange ) ,
groupState : dropNull ( groupState ) ,
2020-09-09 02:25:05 +00:00
} ) ;
attributes = newAttributes ;
finalMessages . push ( groupChangeMessages ) ;
finalMembers . push ( members ) ;
} catch ( error ) {
window . log . error (
2020-11-20 17:30:45 +00:00
` integrateGroupChanges/ ${ logId } : Failed to apply change log, continuing to apply remaining change logs. ` ,
2020-09-09 02:25:05 +00:00
error && error . stack ? error.stack : error
) ;
}
}
}
// If this is our first fetch, we will collapse this down to one set of messages
const isFirstFetch = ! isNumber ( group . revision ) ;
if ( isFirstFetch ) {
// The first array in finalMessages is from the first revision we could process. It
// should contain a message about how we joined the group.
const joinMessages = finalMessages [ 0 ] ;
const alreadyHaveJoinMessage = joinMessages && joinMessages . length > 0 ;
// There have been other changes since that first revision, so we generate diffs for
// the whole of the change since then, likely without the initial join message.
const otherMessages = extractDiffs ( {
old : group ,
current : attributes ,
dropInitialJoinMessage : alreadyHaveJoinMessage ,
} ) ;
const groupChangeMessages = alreadyHaveJoinMessage
? [ joinMessages [ 0 ] , . . . otherMessages ]
: otherMessages ;
return {
newAttributes : attributes ,
groupChangeMessages ,
members : flatten ( finalMembers ) ,
} ;
}
return {
newAttributes : attributes ,
groupChangeMessages : flatten ( finalMessages ) ,
members : flatten ( finalMembers ) ,
} ;
}
async function integrateGroupChange ( {
group ,
groupChange ,
2020-10-06 17:06:34 +00:00
groupState ,
2020-09-09 02:25:05 +00:00
newRevision ,
} : {
group : ConversationAttributesType ;
2021-06-22 14:46:42 +00:00
groupChange? : Proto.IGroupChange ;
groupState? : Proto.IGroup ;
2020-09-09 02:25:05 +00:00
newRevision : number ;
} ) : Promise < UpdatesResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
if ( ! group . secretParams ) {
2020-10-06 17:06:34 +00:00
throw new Error (
` integrateGroupChange/ ${ logId } : Group was missing secretParams! `
) ;
2020-09-09 02:25:05 +00:00
}
2021-02-08 21:55:21 +00:00
if ( ! groupChange && ! groupState ) {
throw new Error (
` integrateGroupChange/ ${ logId } : Neither groupChange nor groupState received! `
) ;
2020-09-09 02:25:05 +00:00
}
2020-10-06 17:06:34 +00:00
const isFirstFetch = ! isNumber ( group . revision ) ;
2021-01-29 22:16:48 +00:00
const ourConversationId = window . ConversationController . getOurConversationIdOrThrow ( ) ;
const weAreAwaitingApproval = ( group . pendingAdminApprovalV2 || [ ] ) . find (
item = > item . conversationId === ourConversationId
) ;
2021-02-08 21:55:21 +00:00
// These need to be populated from the groupChange. But we might not get one!
let isChangeSupported = false ;
let isMoreThanOneVersionUp = false ;
2021-06-22 14:46:42 +00:00
let groupChangeActions : undefined | Proto . GroupChange . IActions ;
let decryptedChangeActions : undefined | DecryptedGroupChangeActions ;
2021-02-08 21:55:21 +00:00
let sourceConversationId : undefined | string ;
if ( groupChange ) {
2021-06-22 14:46:42 +00:00
groupChangeActions = Proto . GroupChange . Actions . decode (
groupChange . actions || new FIXMEU8 ( 0 )
2021-02-08 21:55:21 +00:00
) ;
if (
groupChangeActions . version &&
groupChangeActions . version > newRevision
) {
return {
newAttributes : group ,
groupChangeMessages : [ ] ,
members : [ ] ,
} ;
}
decryptedChangeActions = decryptGroupChange (
groupChangeActions ,
group . secretParams ,
logId
) ;
2021-06-22 14:46:42 +00:00
strictAssert (
decryptedChangeActions !== undefined ,
'Should have decrypted group actions'
) ;
2021-02-08 21:55:21 +00:00
const { sourceUuid } = decryptedChangeActions ;
2021-06-22 14:46:42 +00:00
strictAssert ( sourceUuid , 'Should have source UUID' ) ;
2021-02-08 21:55:21 +00:00
const sourceConversation = window . ConversationController . getOrCreate (
sourceUuid ,
'private'
) ;
sourceConversationId = sourceConversation . id ;
isChangeSupported =
! isNumber ( groupChange . changeEpoch ) ||
groupChange . changeEpoch <= SUPPORTED_CHANGE_EPOCH ;
isMoreThanOneVersionUp = Boolean (
groupChangeActions . version &&
isNumber ( group . revision ) &&
groupChangeActions . version > group . revision + 1
) ;
}
2021-01-29 22:16:48 +00:00
if (
2021-02-08 21:55:21 +00:00
! groupChange ||
2021-01-29 22:16:48 +00:00
! isChangeSupported ||
isFirstFetch ||
( isMoreThanOneVersionUp && ! weAreAwaitingApproval )
) {
2020-10-21 00:39:13 +00:00
if ( ! groupState ) {
throw new Error (
` integrateGroupChange/ ${ logId } : No group state, but we can't apply changes! `
) ;
}
2020-10-06 17:06:34 +00:00
window . log . info (
2020-10-21 00:39:13 +00:00
` integrateGroupChange/ ${ logId } : Applying full group state, from version ${ group . revision } to ${ groupState . version } ` ,
{
2021-02-08 21:55:21 +00:00
isChangePresent : Boolean ( groupChange ) ,
2020-10-21 00:39:13 +00:00
isChangeSupported ,
2021-02-08 21:55:21 +00:00
isFirstFetch ,
isMoreThanOneVersionUp ,
weAreAwaitingApproval ,
2020-10-21 00:39:13 +00:00
}
2020-10-06 17:06:34 +00:00
) ;
const decryptedGroupState = decryptGroupState (
groupState ,
group . secretParams ,
logId
) ;
2021-04-07 22:45:31 +00:00
const { newAttributes , newProfileKeys } = await applyGroupState ( {
2020-10-06 17:06:34 +00:00
group ,
groupState : decryptedGroupState ,
sourceConversationId : isFirstFetch ? sourceConversationId : undefined ,
} ) ;
return {
newAttributes ,
groupChangeMessages : extractDiffs ( {
old : group ,
current : newAttributes ,
sourceConversationId : isFirstFetch ? sourceConversationId : undefined ,
} ) ,
2021-04-07 22:45:31 +00:00
members : profileKeysToMembers ( newProfileKeys ) ,
2020-10-06 17:06:34 +00:00
} ;
}
2021-02-08 21:55:21 +00:00
if ( ! sourceConversationId || ! groupChangeActions || ! decryptedChangeActions ) {
throw new Error (
` integrateGroupChange/ ${ logId } : Missing necessary information that should have come from group actions `
) ;
}
2020-10-06 17:06:34 +00:00
window . log . info (
` integrateGroupChange/ ${ logId } : Applying group change actions, from version ${ group . revision } to ${ groupChangeActions . version } `
) ;
2020-09-09 02:25:05 +00:00
const { newAttributes , newProfileKeys } = await applyGroupChange ( {
group ,
actions : decryptedChangeActions ,
2020-10-06 17:06:34 +00:00
sourceConversationId ,
2020-09-09 02:25:05 +00:00
} ) ;
const groupChangeMessages = extractDiffs ( {
old : group ,
current : newAttributes ,
sourceConversationId ,
} ) ;
return {
newAttributes ,
groupChangeMessages ,
2021-04-07 22:45:31 +00:00
members : profileKeysToMembers ( newProfileKeys ) ,
2020-09-09 02:25:05 +00:00
} ;
}
2020-12-01 16:42:35 +00:00
async function getCurrentGroupState ( {
2020-09-09 02:25:05 +00:00
authCredentialBase64 ,
dropInitialJoinMessage ,
group ,
serverPublicParamsBase64 ,
} : {
authCredentialBase64 : string ;
dropInitialJoinMessage? : boolean ;
group : ConversationAttributesType ;
serverPublicParamsBase64 : string ;
} ) : Promise < UpdatesResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
const sender = window . textsecure . messaging ;
if ( ! sender ) {
throw new Error ( 'textsecure.messaging is not available!' ) ;
}
if ( ! group . secretParams ) {
throw new Error ( 'getCurrentGroupState: group was missing secretParams!' ) ;
}
if ( ! group . publicParams ) {
throw new Error ( 'getCurrentGroupState: group was missing publicParams!' ) ;
}
const options = getGroupCredentials ( {
authCredentialBase64 ,
groupPublicParamsBase64 : group.publicParams ,
groupSecretParamsBase64 : group.secretParams ,
serverPublicParamsBase64 ,
} ) ;
const groupState = await sender . getGroup ( options ) ;
const decryptedGroupState = decryptGroupState (
groupState ,
group . secretParams ,
logId
) ;
2021-05-10 22:38:18 +00:00
const oldVersion = group . revision ;
2021-02-19 18:40:41 +00:00
const newVersion = decryptedGroupState . version ;
window . log . info (
` getCurrentGroupState/ ${ logId } : Applying full group state, from version ${ oldVersion } to ${ newVersion } . `
) ;
2021-04-07 22:45:31 +00:00
const { newAttributes , newProfileKeys } = await applyGroupState ( {
2020-10-06 17:06:34 +00:00
group ,
groupState : decryptedGroupState ,
} ) ;
2020-09-09 02:25:05 +00:00
return {
newAttributes ,
groupChangeMessages : extractDiffs ( {
old : group ,
current : newAttributes ,
dropInitialJoinMessage ,
} ) ,
2021-04-07 22:45:31 +00:00
members : profileKeysToMembers ( newProfileKeys ) ,
2020-09-09 02:25:05 +00:00
} ;
}
function extractDiffs ( {
current ,
dropInitialJoinMessage ,
old ,
sourceConversationId ,
} : {
current : ConversationAttributesType ;
dropInitialJoinMessage? : boolean ;
old : ConversationAttributesType ;
sourceConversationId? : string ;
} ) : Array < MessageAttributesType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( old . groupId ) ;
2020-09-09 02:25:05 +00:00
const details : Array < GroupV2ChangeDetailType > = [ ] ;
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
2020-10-06 17:06:34 +00:00
2020-09-09 02:25:05 +00:00
let areWeInGroup = false ;
2020-10-06 17:06:34 +00:00
let areWeInvitedToGroup = false ;
let whoInvitedUsUserId = null ;
2020-09-09 02:25:05 +00:00
2020-12-18 19:27:43 +00:00
// access control
2020-09-09 02:25:05 +00:00
if (
current . accessControl &&
2020-12-18 19:27:43 +00:00
old . accessControl &&
old . accessControl . attributes !== undefined &&
old . accessControl . attributes !== current . accessControl . attributes
2020-09-09 02:25:05 +00:00
) {
details . push ( {
type : 'access-attributes' ,
newPrivilege : current.accessControl.attributes ,
} ) ;
}
if (
current . accessControl &&
2020-12-18 19:27:43 +00:00
old . accessControl &&
old . accessControl . members !== undefined &&
old . accessControl . members !== current . accessControl . members
2020-09-09 02:25:05 +00:00
) {
details . push ( {
type : 'access-members' ,
newPrivilege : current.accessControl.members ,
} ) ;
}
2020-12-18 19:27:43 +00:00
const linkPreviouslyEnabled =
old . accessControl ? . addFromInviteLink === ACCESS_ENUM . ANY ||
old . accessControl ? . addFromInviteLink === ACCESS_ENUM . ADMINISTRATOR ;
const linkCurrentlyEnabled =
current . accessControl ? . addFromInviteLink === ACCESS_ENUM . ANY ||
current . accessControl ? . addFromInviteLink === ACCESS_ENUM . ADMINISTRATOR ;
if ( ! linkPreviouslyEnabled && linkCurrentlyEnabled ) {
details . push ( {
type : 'group-link-add' ,
privilege : current.accessControl?.addFromInviteLink || ACCESS_ENUM . ANY ,
} ) ;
} else if ( linkPreviouslyEnabled && ! linkCurrentlyEnabled ) {
details . push ( {
type : 'group-link-remove' ,
} ) ;
} else if (
linkPreviouslyEnabled &&
linkCurrentlyEnabled &&
old . accessControl ? . addFromInviteLink !==
current . accessControl ? . addFromInviteLink
) {
details . push ( {
type : 'access-invite-link' ,
newPrivilege : current.accessControl?.addFromInviteLink || ACCESS_ENUM . ANY ,
} ) ;
}
// avatar
2020-09-09 02:25:05 +00:00
if (
Boolean ( old . avatar ) !== Boolean ( current . avatar ) ||
old . avatar ? . hash !== current . avatar ? . hash
) {
details . push ( {
type : 'avatar' ,
removed : ! current . avatar ,
} ) ;
}
2020-12-18 19:27:43 +00:00
// name
2020-09-09 02:25:05 +00:00
if ( old . name !== current . name ) {
details . push ( {
type : 'title' ,
newTitle : current.name ,
} ) ;
}
2020-12-18 19:27:43 +00:00
// groupInviteLinkPassword
// Note: we only capture link resets here. Enable/disable are controlled by the
// accessControl.addFromInviteLink
if (
old . groupInviteLinkPassword &&
current . groupInviteLinkPassword &&
old . groupInviteLinkPassword !== current . groupInviteLinkPassword
) {
details . push ( {
type : 'group-link-reset' ,
} ) ;
}
2021-06-02 00:24:28 +00:00
// description
if ( old . description !== current . description ) {
details . push ( {
type : 'description' ,
removed : ! current . description ,
2021-06-04 16:27:04 +00:00
description : current.description ,
2021-06-02 00:24:28 +00:00
} ) ;
}
2020-09-09 02:25:05 +00:00
// No disappearing message timer check here - see below
2020-12-18 19:27:43 +00:00
// membersV2
2020-09-09 02:25:05 +00:00
const oldMemberLookup : Dictionary < GroupV2MemberType > = fromPairs (
( old . membersV2 || [ ] ) . map ( member = > [ member . conversationId , member ] )
) ;
const oldPendingMemberLookup : Dictionary < GroupV2PendingMemberType > = fromPairs (
( old . pendingMembersV2 || [ ] ) . map ( member = > [ member . conversationId , member ] )
) ;
2020-12-18 19:27:43 +00:00
const oldPendingAdminApprovalLookup : Dictionary < GroupV2PendingAdminApprovalType > = fromPairs (
( old . pendingAdminApprovalV2 || [ ] ) . map ( member = > [
member . conversationId ,
member ,
] )
) ;
2020-09-09 02:25:05 +00:00
( current . membersV2 || [ ] ) . forEach ( currentMember = > {
const { conversationId } = currentMember ;
if ( ourConversationId && conversationId === ourConversationId ) {
areWeInGroup = true ;
}
const oldMember = oldMemberLookup [ conversationId ] ;
if ( ! oldMember ) {
const pendingMember = oldPendingMemberLookup [ conversationId ] ;
2020-09-28 17:22:57 +00:00
if ( pendingMember ) {
2020-09-09 02:25:05 +00:00
details . push ( {
type : 'member-add-from-invite' ,
conversationId ,
inviter : pendingMember.addedByUserId ,
} ) ;
2020-12-18 19:27:43 +00:00
} else if ( currentMember . joinedFromLink ) {
details . push ( {
type : 'member-add-from-link' ,
conversationId ,
} ) ;
} else if ( currentMember . approvedByAdmin ) {
details . push ( {
type : 'member-add-from-admin-approval' ,
conversationId ,
} ) ;
2020-09-09 02:25:05 +00:00
} else {
details . push ( {
type : 'member-add' ,
conversationId ,
} ) ;
}
} else if ( oldMember . role !== currentMember . role ) {
details . push ( {
type : 'member-privilege' ,
conversationId ,
newPrivilege : currentMember.role ,
} ) ;
}
2020-12-18 19:27:43 +00:00
// We don't want to generate an admin-approval-remove event for this newly-added
// member. But we don't know for sure if this is an admin approval; for that we
// consulted the approvedByAdmin flag saved on the member.
delete oldPendingAdminApprovalLookup [ conversationId ] ;
// If we capture a pending remove here, it's an 'accept invitation', and we don't
// want to generate a pending-remove event for it
delete oldPendingMemberLookup [ conversationId ] ;
2020-09-09 02:25:05 +00:00
// This deletion makes it easier to capture removals
delete oldMemberLookup [ conversationId ] ;
} ) ;
const removedMemberIds = Object . keys ( oldMemberLookup ) ;
removedMemberIds . forEach ( conversationId = > {
details . push ( {
type : 'member-remove' ,
conversationId ,
} ) ;
} ) ;
2020-12-18 19:27:43 +00:00
// pendingMembersV2
2020-09-09 02:25:05 +00:00
let lastPendingConversationId : string | undefined ;
2020-12-18 19:27:43 +00:00
let pendingCount = 0 ;
2020-09-09 02:25:05 +00:00
( current . pendingMembersV2 || [ ] ) . forEach ( currentPendingMember = > {
const { conversationId } = currentPendingMember ;
const oldPendingMember = oldPendingMemberLookup [ conversationId ] ;
2020-10-06 17:06:34 +00:00
if ( ourConversationId && conversationId === ourConversationId ) {
areWeInvitedToGroup = true ;
whoInvitedUsUserId = currentPendingMember . addedByUserId ;
}
2020-09-09 02:25:05 +00:00
if ( ! oldPendingMember ) {
lastPendingConversationId = conversationId ;
2020-12-18 19:27:43 +00:00
pendingCount += 1 ;
2020-09-09 02:25:05 +00:00
}
// This deletion makes it easier to capture removals
delete oldPendingMemberLookup [ conversationId ] ;
} ) ;
2020-12-18 19:27:43 +00:00
if ( pendingCount > 1 ) {
2020-09-09 02:25:05 +00:00
details . push ( {
type : 'pending-add-many' ,
2020-12-18 19:27:43 +00:00
count : pendingCount ,
2020-09-09 02:25:05 +00:00
} ) ;
2020-12-18 19:27:43 +00:00
} else if ( pendingCount === 1 ) {
2020-09-09 02:25:05 +00:00
if ( lastPendingConversationId ) {
details . push ( {
type : 'pending-add-one' ,
conversationId : lastPendingConversationId ,
} ) ;
} else {
window . log . warn (
2020-12-18 19:27:43 +00:00
` extractDiffs/ ${ logId } : pendingCount was 1, no last conversationId available `
2020-09-09 02:25:05 +00:00
) ;
}
}
// Note: The only members left over here should be people who were moved from the
// pending list but also not added to the group at the same time.
const removedPendingMemberIds = Object . keys ( oldPendingMemberLookup ) ;
if ( removedPendingMemberIds . length > 1 ) {
const firstConversationId = removedPendingMemberIds [ 0 ] ;
const firstRemovedMember = oldPendingMemberLookup [ firstConversationId ] ;
const inviter = firstRemovedMember . addedByUserId ;
const allSameInviter = removedPendingMemberIds . every (
id = > oldPendingMemberLookup [ id ] . addedByUserId === inviter
) ;
details . push ( {
type : 'pending-remove-many' ,
count : removedPendingMemberIds.length ,
inviter : allSameInviter ? inviter : undefined ,
} ) ;
} else if ( removedPendingMemberIds . length === 1 ) {
const conversationId = removedPendingMemberIds [ 0 ] ;
const removedMember = oldPendingMemberLookup [ conversationId ] ;
details . push ( {
type : 'pending-remove-one' ,
conversationId ,
inviter : removedMember.addedByUserId ,
} ) ;
}
2020-12-18 19:27:43 +00:00
// pendingAdminApprovalV2
( current . pendingAdminApprovalV2 || [ ] ) . forEach (
currentPendingAdminAprovalMember = > {
const { conversationId } = currentPendingAdminAprovalMember ;
const oldPendingMember = oldPendingAdminApprovalLookup [ conversationId ] ;
if ( ! oldPendingMember ) {
details . push ( {
type : 'admin-approval-add-one' ,
conversationId ,
} ) ;
}
// This deletion makes it easier to capture removals
delete oldPendingAdminApprovalLookup [ conversationId ] ;
}
) ;
// Note: The only members left over here should be people who were moved from the
// pendingAdminApproval list but also not added to the group at the same time.
const removedPendingAdminApprovalIds = Object . keys (
oldPendingAdminApprovalLookup
) ;
removedPendingAdminApprovalIds . forEach ( conversationId = > {
details . push ( {
type : 'admin-approval-remove-one' ,
conversationId ,
} ) ;
} ) ;
2021-07-20 20:18:35 +00:00
// announcementsOnly
2021-07-21 17:58:39 +00:00
if ( Boolean ( old . announcementsOnly ) !== Boolean ( current . announcementsOnly ) ) {
2021-07-20 20:18:35 +00:00
details . push ( {
type : 'announcements-only' ,
announcementsOnly : Boolean ( current . announcementsOnly ) ,
} ) ;
}
2020-12-18 19:27:43 +00:00
// final processing
2020-09-09 02:25:05 +00:00
let message : MessageAttributesType | undefined ;
let timerNotification : MessageAttributesType | undefined ;
const conversation = sourceConversationId
? window . ConversationController . get ( sourceConversationId )
: null ;
const sourceUuid = conversation ? conversation . get ( 'uuid' ) : undefined ;
const firstUpdate = ! isNumber ( old . revision ) ;
2020-10-06 17:06:34 +00:00
// Here we hardcode initial messages if this is our first time processing data this
// group. Ideally we can collapse it down to just one of: 'you were added',
// 'you were invited', or 'you created.'
2020-11-10 15:15:37 +00:00
if ( firstUpdate && ourConversationId && areWeInvitedToGroup ) {
// Note, we will add 'you were invited' to group even if dropInitialJoinMessage = true
2020-10-06 17:06:34 +00:00
message = {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
2020-11-10 15:15:37 +00:00
from : whoInvitedUsUserId || sourceConversationId ,
2020-10-06 17:06:34 +00:00
details : [
{
2020-11-10 15:15:37 +00:00
type : 'pending-add-one' ,
conversationId : ourConversationId ,
2020-10-06 17:06:34 +00:00
} ,
] ,
} ,
} ;
2020-11-10 15:15:37 +00:00
} else if ( firstUpdate && dropInitialJoinMessage ) {
// None of the rest of the messages should be added if dropInitialJoinMessage = true
message = undefined ;
} else if (
firstUpdate &&
ourConversationId &&
sourceConversationId &&
sourceConversationId === ourConversationId
) {
2020-10-06 17:06:34 +00:00
message = {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
2020-11-10 15:15:37 +00:00
from : sourceConversationId ,
2020-10-06 17:06:34 +00:00
details : [
{
2020-11-10 15:15:37 +00:00
type : 'create' ,
2020-10-06 17:06:34 +00:00
} ,
] ,
} ,
} ;
2020-09-09 02:25:05 +00:00
} else if ( firstUpdate && ourConversationId && areWeInGroup ) {
message = {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
2020-10-06 17:06:34 +00:00
from : sourceConversationId ,
2020-09-09 02:25:05 +00:00
details : [
{
type : 'member-add' ,
conversationId : ourConversationId ,
} ,
] ,
} ,
} ;
2020-10-06 17:06:34 +00:00
} else if ( firstUpdate ) {
message = {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
groupV2Change : {
from : sourceConversationId ,
details : [
{
type : 'create' ,
} ,
] ,
} ,
} ;
2020-09-09 02:25:05 +00:00
} else if ( details . length > 0 ) {
message = {
. . . generateBasicMessage ( ) ,
type : 'group-v2-change' ,
sourceUuid ,
groupV2Change : {
from : sourceConversationId ,
details ,
} ,
} ;
}
// This is checked differently, because it needs to be its own entry in the timeline,
// with its own icon, etc.
if (
// Turn on or turned off
Boolean ( old . expireTimer ) !== Boolean ( current . expireTimer ) ||
// Still on, but changed value
( Boolean ( old . expireTimer ) &&
Boolean ( current . expireTimer ) &&
old . expireTimer !== current . expireTimer )
) {
timerNotification = {
. . . generateBasicMessage ( ) ,
type : 'timer-notification' ,
sourceUuid ,
2021-06-22 14:46:42 +00:00
flags : Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE ,
2020-09-09 02:25:05 +00:00
expirationTimerUpdate : {
expireTimer : current.expireTimer || 0 ,
sourceUuid ,
} ,
} ;
}
const result = compact ( [ message , timerNotification ] ) ;
window . log . info (
` extractDiffs/ ${ logId } complete, generated ${ result . length } change messages `
) ;
return result ;
}
2021-04-07 22:45:31 +00:00
function profileKeysToMembers ( items : Array < GroupChangeMemberType > ) {
return items . map ( item = > ( {
2021-06-22 14:46:42 +00:00
profileKey : Bytes.toBase64 ( item . profileKey ) ,
2021-04-07 22:45:31 +00:00
uuid : item.uuid ,
2020-09-09 02:25:05 +00:00
} ) ) ;
}
type GroupChangeMemberType = {
2021-06-22 14:46:42 +00:00
profileKey : Uint8Array ;
2020-09-09 02:25:05 +00:00
uuid : string ;
} ;
2021-04-07 22:45:31 +00:00
type GroupApplyResultType = {
2020-09-09 02:25:05 +00:00
newAttributes : ConversationAttributesType ;
newProfileKeys : Array < GroupChangeMemberType > ;
} ;
async function applyGroupChange ( {
actions ,
2020-10-06 17:06:34 +00:00
group ,
sourceConversationId ,
2020-09-09 02:25:05 +00:00
} : {
2021-06-22 14:46:42 +00:00
actions : DecryptedGroupChangeActions ;
2020-10-06 17:06:34 +00:00
group : ConversationAttributesType ;
sourceConversationId : string ;
2021-04-07 22:45:31 +00:00
} ) : Promise < GroupApplyResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-10-06 17:06:34 +00:00
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2020-10-06 17:06:34 +00:00
2020-09-09 02:25:05 +00:00
const version = actions . version || 0 ;
2020-11-20 17:30:45 +00:00
const result = { . . . group } ;
2020-09-09 02:25:05 +00:00
const newProfileKeys : Array < GroupChangeMemberType > = [ ] ;
const members : Dictionary < GroupV2MemberType > = fromPairs (
( result . membersV2 || [ ] ) . map ( member = > [ member . conversationId , member ] )
) ;
const pendingMembers : Dictionary < GroupV2PendingMemberType > = fromPairs (
( result . pendingMembersV2 || [ ] ) . map ( member = > [
member . conversationId ,
member ,
] )
) ;
2020-12-18 19:27:43 +00:00
const pendingAdminApprovalMembers : Dictionary < GroupV2PendingAdminApprovalType > = fromPairs (
( result . pendingAdminApprovalV2 || [ ] ) . map ( member = > [
member . conversationId ,
member ,
] )
) ;
2020-09-09 02:25:05 +00:00
// version?: number;
result . revision = version ;
2021-06-22 14:46:42 +00:00
// addMembers?: Array<GroupChange.Actions.AddMemberAction>;
2020-09-11 19:37:01 +00:00
( actions . addMembers || [ ] ) . forEach ( addMember = > {
2020-09-09 02:25:05 +00:00
const { added } = addMember ;
if ( ! added ) {
throw new Error ( 'applyGroupChange: addMember.added is missing' ) ;
}
const conversation = window . ConversationController . getOrCreate (
added . userId ,
2021-04-07 22:45:31 +00:00
'private'
2020-09-09 02:25:05 +00:00
) ;
if ( members [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to add member failed; already in members. `
) ;
return ;
}
members [ conversation . id ] = {
conversationId : conversation.id ,
role : added.role || MEMBER_ROLE_ENUM . DEFAULT ,
joinedAtVersion : version ,
2020-12-18 19:27:43 +00:00
joinedFromLink : addMember.joinFromInviteLink || false ,
2020-09-09 02:25:05 +00:00
} ;
if ( pendingMembers [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Removing newly-added member from pendingMembers. `
) ;
delete pendingMembers [ conversation . id ] ;
}
2020-10-06 17:06:34 +00:00
// Capture who added us
if (
ourConversationId &&
sourceConversationId &&
conversation . id === ourConversationId
) {
result . addedBy = sourceConversationId ;
}
2020-09-09 02:25:05 +00:00
if ( added . profileKey ) {
newProfileKeys . push ( {
profileKey : added.profileKey ,
uuid : added.userId ,
} ) ;
}
} ) ;
2021-06-22 14:46:42 +00:00
// deleteMembers?: Array<GroupChange.Actions.DeleteMemberAction>;
2020-09-09 02:25:05 +00:00
( actions . deleteMembers || [ ] ) . forEach ( deleteMember = > {
const { deletedUserId } = deleteMember ;
if ( ! deletedUserId ) {
throw new Error (
'applyGroupChange: deleteMember.deletedUserId is missing'
) ;
}
const conversation = window . ConversationController . getOrCreate (
deletedUserId ,
'private'
) ;
if ( members [ conversation . id ] ) {
delete members [ conversation . id ] ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to remove member failed; was not in members. `
) ;
}
} ) ;
2021-06-22 14:46:42 +00:00
// modifyMemberRoles?: Array<GroupChange.Actions.ModifyMemberRoleAction>;
2020-09-09 02:25:05 +00:00
( actions . modifyMemberRoles || [ ] ) . forEach ( modifyMemberRole = > {
const { role , userId } = modifyMemberRole ;
if ( ! role || ! userId ) {
throw new Error ( 'applyGroupChange: modifyMemberRole had a missing value' ) ;
}
const conversation = window . ConversationController . getOrCreate (
userId ,
'private'
) ;
if ( members [ conversation . id ] ) {
members [ conversation . id ] = {
. . . members [ conversation . id ] ,
role ,
} ;
} else {
throw new Error (
'applyGroupChange: modifyMemberRole tried to modify nonexistent member'
) ;
}
} ) ;
2020-09-11 19:37:01 +00:00
// modifyMemberProfileKeys?:
2021-06-22 14:46:42 +00:00
// Array<GroupChange.Actions.ModifyMemberProfileKeyAction>;
2020-09-09 02:25:05 +00:00
( actions . modifyMemberProfileKeys || [ ] ) . forEach ( modifyMemberProfileKey = > {
const { profileKey , uuid } = modifyMemberProfileKey ;
if ( ! profileKey || ! uuid ) {
throw new Error (
'applyGroupChange: modifyMemberProfileKey had a missing value'
) ;
}
newProfileKeys . push ( {
profileKey ,
uuid ,
} ) ;
} ) ;
2020-12-18 19:27:43 +00:00
// addPendingMembers?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.AddMemberPendingProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2020-09-09 02:25:05 +00:00
( actions . addPendingMembers || [ ] ) . forEach ( addPendingMember = > {
const { added } = addPendingMember ;
if ( ! added || ! added . member ) {
throw new Error (
2020-12-18 19:27:43 +00:00
'applyGroupChange: addPendingMembers had a missing value'
2020-09-09 02:25:05 +00:00
) ;
}
const conversation = window . ConversationController . getOrCreate (
added . member . userId ,
'private'
) ;
if ( members [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to add pendingMember failed; was already in members. `
) ;
return ;
}
if ( pendingMembers [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to add pendingMember failed; was already in pendingMembers. `
) ;
return ;
}
pendingMembers [ conversation . id ] = {
conversationId : conversation.id ,
addedByUserId : added.addedByUserId ,
timestamp : added.timestamp ,
2020-12-01 23:45:39 +00:00
role : added.member.role || MEMBER_ROLE_ENUM . DEFAULT ,
2020-09-09 02:25:05 +00:00
} ;
if ( added . member && added . member . profileKey ) {
newProfileKeys . push ( {
profileKey : added.member.profileKey ,
uuid : added.member.userId ,
} ) ;
}
} ) ;
2020-12-18 19:27:43 +00:00
// deletePendingMembers?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.DeleteMemberPendingProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2020-09-09 02:25:05 +00:00
( actions . deletePendingMembers || [ ] ) . forEach ( deletePendingMember = > {
const { deletedUserId } = deletePendingMember ;
if ( ! deletedUserId ) {
throw new Error (
'applyGroupChange: deletePendingMember.deletedUserId is null!'
) ;
}
const conversation = window . ConversationController . getOrCreate (
deletedUserId ,
'private'
) ;
if ( pendingMembers [ conversation . id ] ) {
delete pendingMembers [ conversation . id ] ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to remove pendingMember failed; was not in pendingMembers. `
) ;
}
} ) ;
2020-12-18 19:27:43 +00:00
// promotePendingMembers?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.PromoteMemberPendingProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2020-09-09 02:25:05 +00:00
( actions . promotePendingMembers || [ ] ) . forEach ( promotePendingMember = > {
const { profileKey , uuid } = promotePendingMember ;
if ( ! profileKey || ! uuid ) {
throw new Error (
'applyGroupChange: promotePendingMember had a missing value'
) ;
}
const conversation = window . ConversationController . getOrCreate (
uuid ,
2021-04-07 22:45:31 +00:00
'private'
2020-09-09 02:25:05 +00:00
) ;
2020-12-01 23:45:39 +00:00
const previousRecord = pendingMembers [ conversation . id ] ;
2020-09-09 02:25:05 +00:00
if ( pendingMembers [ conversation . id ] ) {
delete pendingMembers [ conversation . id ] ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to promote pendingMember failed; was not in pendingMembers. `
) ;
}
if ( members [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to promote pendingMember failed; was already in members. `
) ;
return ;
}
members [ conversation . id ] = {
conversationId : conversation.id ,
joinedAtVersion : version ,
2020-12-01 23:45:39 +00:00
role : previousRecord.role || MEMBER_ROLE_ENUM . DEFAULT ,
2020-09-09 02:25:05 +00:00
} ;
newProfileKeys . push ( {
profileKey ,
uuid ,
} ) ;
} ) ;
2021-06-22 14:46:42 +00:00
// modifyTitle?: GroupChange.Actions.ModifyTitleAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyTitle ) {
2020-09-11 19:37:01 +00:00
const { title } = actions . modifyTitle ;
2020-09-09 02:25:05 +00:00
if ( title && title . content === 'title' ) {
result . name = title . title ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Clearing group title due to missing data. `
) ;
result . name = undefined ;
}
}
2021-06-22 14:46:42 +00:00
// modifyAvatar?: GroupChange.Actions.ModifyAvatarAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyAvatar ) {
2020-09-11 19:37:01 +00:00
const { avatar } = actions . modifyAvatar ;
2021-06-22 14:46:42 +00:00
await applyNewAvatar ( dropNull ( avatar ) , result , logId ) ;
2020-09-09 02:25:05 +00:00
}
2020-09-11 19:37:01 +00:00
// modifyDisappearingMessagesTimer?:
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyDisappearingMessagesTimerAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyDisappearingMessagesTimer ) {
2021-06-22 14:46:42 +00:00
const disappearingMessagesTimer : Proto.GroupAttributeBlob | undefined =
2020-09-09 02:25:05 +00:00
actions . modifyDisappearingMessagesTimer . timer ;
if (
disappearingMessagesTimer &&
disappearingMessagesTimer . content === 'disappearingMessagesDuration'
) {
result . expireTimer =
disappearingMessagesTimer . disappearingMessagesDuration ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Clearing group expireTimer due to missing data. `
) ;
result . expireTimer = undefined ;
}
}
result . accessControl = result . accessControl || {
members : ACCESS_ENUM.MEMBER ,
attributes : ACCESS_ENUM.MEMBER ,
2020-12-18 19:27:43 +00:00
addFromInviteLink : ACCESS_ENUM.UNSATISFIABLE ,
2020-09-09 02:25:05 +00:00
} ;
2020-09-11 19:37:01 +00:00
// modifyAttributesAccess?:
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyAttributesAccessControlAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyAttributesAccess ) {
result . accessControl = {
. . . result . accessControl ,
attributes :
actions . modifyAttributesAccess . attributesAccess || ACCESS_ENUM . MEMBER ,
} ;
}
2021-06-22 14:46:42 +00:00
// modifyMemberAccess?: GroupChange.Actions.ModifyMembersAccessControlAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyMemberAccess ) {
result . accessControl = {
. . . result . accessControl ,
2020-09-10 20:06:26 +00:00
members : actions.modifyMemberAccess.membersAccess || ACCESS_ENUM . MEMBER ,
2020-09-09 02:25:05 +00:00
} ;
}
2020-12-18 19:27:43 +00:00
// modifyAddFromInviteLinkAccess?:
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction;
2020-12-18 19:27:43 +00:00
if ( actions . modifyAddFromInviteLinkAccess ) {
result . accessControl = {
. . . result . accessControl ,
addFromInviteLink :
actions . modifyAddFromInviteLinkAccess . addFromInviteLinkAccess ||
ACCESS_ENUM . UNSATISFIABLE ,
} ;
}
// addMemberPendingAdminApprovals?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.AddMemberPendingAdminApprovalAction
2020-12-18 19:27:43 +00:00
// >;
( actions . addMemberPendingAdminApprovals || [ ] ) . forEach (
pendingAdminApproval = > {
const { added } = pendingAdminApproval ;
if ( ! added ) {
throw new Error (
'applyGroupChange: modifyMemberProfileKey had a missing value'
) ;
}
const conversation = window . ConversationController . getOrCreate (
added . userId ,
'private'
) ;
if ( members [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to add pending admin approval failed; was already in members. `
) ;
return ;
}
if ( pendingMembers [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to add pending admin approval failed; was already in pendingMembers. `
) ;
return ;
}
if ( pendingAdminApprovalMembers [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to add pending admin approval failed; was already in pendingAdminApprovalMembers. `
) ;
return ;
}
pendingAdminApprovalMembers [ conversation . id ] = {
conversationId : conversation.id ,
timestamp : added.timestamp ,
} ;
if ( added . profileKey ) {
newProfileKeys . push ( {
profileKey : added.profileKey ,
uuid : added.userId ,
} ) ;
}
}
) ;
// deleteMemberPendingAdminApprovals?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.DeleteMemberPendingAdminApprovalAction
2020-12-18 19:27:43 +00:00
// >;
( actions . deleteMemberPendingAdminApprovals || [ ] ) . forEach (
deleteAdminApproval = > {
const { deletedUserId } = deleteAdminApproval ;
if ( ! deletedUserId ) {
throw new Error (
'applyGroupChange: deleteAdminApproval.deletedUserId is null!'
) ;
}
const conversation = window . ConversationController . getOrCreate (
deletedUserId ,
'private'
) ;
if ( pendingAdminApprovalMembers [ conversation . id ] ) {
delete pendingAdminApprovalMembers [ conversation . id ] ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to remove pendingAdminApproval failed; was not in pendingAdminApprovalMembers. `
) ;
}
}
) ;
// promoteMemberPendingAdminApprovals?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.PromoteMemberPendingAdminApprovalAction
2020-12-18 19:27:43 +00:00
// >;
( actions . promoteMemberPendingAdminApprovals || [ ] ) . forEach (
promoteAdminApproval = > {
const { userId , role } = promoteAdminApproval ;
if ( ! userId ) {
throw new Error (
'applyGroupChange: promoteAdminApproval had a missing value'
) ;
}
const conversation = window . ConversationController . getOrCreate (
userId ,
'private'
) ;
if ( pendingAdminApprovalMembers [ conversation . id ] ) {
delete pendingAdminApprovalMembers [ conversation . id ] ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to promote pendingAdminApproval failed; was not in pendingAdminApprovalMembers. `
) ;
}
if ( pendingMembers [ conversation . id ] ) {
delete pendingAdminApprovalMembers [ conversation . id ] ;
window . log . warn (
` applyGroupChange/ ${ logId } : Deleted pendingAdminApproval from pendingMembers. `
) ;
}
if ( members [ conversation . id ] ) {
window . log . warn (
` applyGroupChange/ ${ logId } : Attempt to promote pendingMember failed; was already in members. `
) ;
return ;
}
members [ conversation . id ] = {
conversationId : conversation.id ,
joinedAtVersion : version ,
role : role || MEMBER_ROLE_ENUM . DEFAULT ,
approvedByAdmin : true ,
} ;
}
) ;
2021-06-22 14:46:42 +00:00
// modifyInviteLinkPassword?: GroupChange.Actions.ModifyInviteLinkPasswordAction;
2020-12-18 19:27:43 +00:00
if ( actions . modifyInviteLinkPassword ) {
const { inviteLinkPassword } = actions . modifyInviteLinkPassword ;
if ( inviteLinkPassword ) {
result . groupInviteLinkPassword = inviteLinkPassword ;
} else {
result . groupInviteLinkPassword = undefined ;
}
}
2021-06-22 14:46:42 +00:00
// modifyDescription?: GroupChange.Actions.ModifyDescriptionAction;
2021-06-02 00:24:28 +00:00
if ( actions . modifyDescription ) {
const { descriptionBytes } = actions . modifyDescription ;
if ( descriptionBytes && descriptionBytes . content === 'descriptionText' ) {
result . description = descriptionBytes . descriptionText ;
} else {
window . log . warn (
` applyGroupChange/ ${ logId } : Clearing group description due to missing data. `
) ;
result . description = undefined ;
}
}
2021-07-21 17:58:39 +00:00
if ( actions . modifyAnnouncementsOnly ) {
const { announcementsOnly } = actions . modifyAnnouncementsOnly ;
result . announcementsOnly = announcementsOnly ;
}
2020-09-09 02:25:05 +00:00
if ( ourConversationId ) {
result . left = ! members [ ourConversationId ] ;
}
// Go from lookups back to arrays
result . membersV2 = values ( members ) ;
result . pendingMembersV2 = values ( pendingMembers ) ;
2020-12-18 19:27:43 +00:00
result . pendingAdminApprovalV2 = values ( pendingAdminApprovalMembers ) ;
2020-09-09 02:25:05 +00:00
return {
newAttributes : result ,
newProfileKeys ,
} ;
}
2021-02-10 22:39:26 +00:00
export async function decryptGroupAvatar (
avatarKey : string ,
secretParamsBase64 : string
) : Promise < ArrayBuffer > {
const sender = window . textsecure . messaging ;
if ( ! sender ) {
throw new Error (
'decryptGroupAvatar: textsecure.messaging is not available!'
) ;
}
2021-06-22 14:46:42 +00:00
const ciphertext = new FIXMEU8 ( await sender . getGroupAvatar ( avatarKey ) ) ;
2021-02-10 22:39:26 +00:00
const clientZkGroupCipher = getClientZkGroupCipher ( secretParamsBase64 ) ;
const plaintext = decryptGroupBlob ( clientZkGroupCipher , ciphertext ) ;
2021-06-22 14:46:42 +00:00
const blob = Proto . GroupAttributeBlob . decode ( plaintext ) ;
2021-02-10 22:39:26 +00:00
if ( blob . content !== 'avatar' ) {
throw new Error (
` decryptGroupAvatar: Returned blob had incorrect content: ${ blob . content } `
) ;
}
2021-06-22 14:46:42 +00:00
return typedArrayToArrayBuffer ( blob . avatar ) ;
2021-02-10 22:39:26 +00:00
}
2020-09-11 19:37:01 +00:00
// Ovewriting result.avatar as part of functionality
/* eslint-disable no-param-reassign */
2021-01-29 22:16:48 +00:00
export async function applyNewAvatar (
2020-09-09 02:25:05 +00:00
newAvatar : string | undefined ,
2021-01-29 22:16:48 +00:00
result : Pick < ConversationAttributesType , ' avatar ' | ' secretParams ' > ,
2020-09-09 02:25:05 +00:00
logId : string
2021-01-29 22:16:48 +00:00
) : Promise < void > {
2020-09-09 02:25:05 +00:00
try {
// Avatar has been dropped
if ( ! newAvatar && result . avatar ) {
await window . Signal . Migrations . deleteAttachmentData ( result . avatar . path ) ;
result . avatar = undefined ;
}
// Group has avatar; has it changed?
if ( newAvatar && ( ! result . avatar || result . avatar . url !== newAvatar ) ) {
if ( ! result . secretParams ) {
throw new Error ( 'applyNewAvatar: group was missing secretParams!' ) ;
}
2021-02-10 22:39:26 +00:00
const data = await decryptGroupAvatar ( newAvatar , result . secretParams ) ;
2020-09-09 02:25:05 +00:00
const hash = await computeHash ( data ) ;
if ( result . avatar && result . avatar . path && result . avatar . hash !== hash ) {
await window . Signal . Migrations . deleteAttachmentData ( result . avatar . path ) ;
result . avatar = undefined ;
}
if ( ! result . avatar ) {
const path = await window . Signal . Migrations . writeNewAttachmentData (
data
) ;
result . avatar = {
url : newAvatar ,
path ,
hash ,
} ;
}
}
} catch ( error ) {
window . log . warn (
` applyNewAvatar/ ${ logId } Failed to handle avatar, clearing it ` ,
error . stack
) ;
if ( result . avatar && result . avatar . path ) {
await window . Signal . Migrations . deleteAttachmentData ( result . avatar . path ) ;
}
result . avatar = undefined ;
}
}
2020-09-11 19:37:01 +00:00
/* eslint-enable no-param-reassign */
2020-09-09 02:25:05 +00:00
2020-10-06 17:06:34 +00:00
async function applyGroupState ( {
group ,
groupState ,
sourceConversationId ,
} : {
group : ConversationAttributesType ;
2021-06-22 14:46:42 +00:00
groupState : DecryptedGroupState ;
2020-10-06 17:06:34 +00:00
sourceConversationId? : string ;
2021-04-07 22:45:31 +00:00
} ) : Promise < GroupApplyResultType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2020-09-09 02:25:05 +00:00
const version = groupState . version || 0 ;
2020-11-20 17:30:45 +00:00
const result = { . . . group } ;
2021-04-07 22:45:31 +00:00
const newProfileKeys : Array < GroupChangeMemberType > = [ ] ;
2020-09-09 02:25:05 +00:00
// version
result . revision = version ;
// title
// Note: During decryption, title becomes a GroupAttributeBlob
2020-09-11 19:37:01 +00:00
const { title } = groupState ;
2020-09-09 02:25:05 +00:00
if ( title && title . content === 'title' ) {
result . name = title . title ;
} else {
result . name = undefined ;
}
// avatar
2021-06-22 14:46:42 +00:00
await applyNewAvatar ( dropNull ( groupState . avatar ) , result , logId ) ;
2020-09-09 02:25:05 +00:00
// disappearingMessagesTimer
// Note: during decryption, disappearingMessageTimer becomes a GroupAttributeBlob
2020-09-11 19:37:01 +00:00
const { disappearingMessagesTimer } = groupState ;
2020-09-09 02:25:05 +00:00
if (
disappearingMessagesTimer &&
disappearingMessagesTimer . content === 'disappearingMessagesDuration'
) {
result . expireTimer = disappearingMessagesTimer . disappearingMessagesDuration ;
} else {
result . expireTimer = undefined ;
}
// accessControl
const { accessControl } = groupState ;
result . accessControl = {
attributes :
( accessControl && accessControl . attributes ) || ACCESS_ENUM . MEMBER ,
members : ( accessControl && accessControl . members ) || ACCESS_ENUM . MEMBER ,
2020-12-18 19:27:43 +00:00
addFromInviteLink :
( accessControl && accessControl . addFromInviteLink ) ||
ACCESS_ENUM . UNSATISFIABLE ,
2020-09-09 02:25:05 +00:00
} ;
// Optimization: we assume we have left the group unless we are found in members
result . left = true ;
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
// members
if ( groupState . members ) {
2021-06-22 14:46:42 +00:00
result . membersV2 = groupState . members . map ( member = > {
2020-09-09 02:25:05 +00:00
const conversation = window . ConversationController . getOrCreate (
member . userId ,
2021-04-07 22:45:31 +00:00
'private'
2020-09-09 02:25:05 +00:00
) ;
if ( ourConversationId && conversation . id === ourConversationId ) {
result . left = false ;
2020-10-06 17:06:34 +00:00
// Capture who added us if we were previously not in group
if (
sourceConversationId &&
( result . membersV2 || [ ] ) . every (
item = > item . conversationId !== ourConversationId
)
) {
result . addedBy = sourceConversationId ;
}
2020-09-09 02:25:05 +00:00
}
2020-12-01 23:45:39 +00:00
if ( ! isValidRole ( member . role ) ) {
2020-12-18 19:27:43 +00:00
throw new Error (
` applyGroupState: Member had invalid role ${ member . role } `
) ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
if ( member . profileKey ) {
newProfileKeys . push ( {
profileKey : member.profileKey ,
uuid : member.userId ,
} ) ;
}
2021-04-07 22:45:31 +00:00
2020-09-09 02:25:05 +00:00
return {
2020-12-01 23:45:39 +00:00
role : member.role || MEMBER_ROLE_ENUM . DEFAULT ,
2020-09-09 02:25:05 +00:00
joinedAtVersion : member.joinedAtVersion || version ,
conversationId : conversation.id ,
} ;
} ) ;
}
2020-12-18 19:27:43 +00:00
// membersPendingProfileKey
if ( groupState . membersPendingProfileKey ) {
result . pendingMembersV2 = groupState . membersPendingProfileKey . map (
2021-06-22 14:46:42 +00:00
member = > {
2020-09-09 02:25:05 +00:00
let pending ;
let invitedBy ;
if ( member . member && member . member . userId ) {
pending = window . ConversationController . getOrCreate (
member . member . userId ,
2021-04-07 22:45:31 +00:00
'private'
2020-09-09 02:25:05 +00:00
) ;
} else {
2020-12-01 23:45:39 +00:00
throw new Error (
2020-12-18 19:27:43 +00:00
'applyGroupState: Member pending profile key did not have an associated userId'
2020-12-01 23:45:39 +00:00
) ;
2020-09-09 02:25:05 +00:00
}
if ( member . addedByUserId ) {
invitedBy = window . ConversationController . getOrCreate (
member . addedByUserId ,
'private'
) ;
} else {
2020-12-01 23:45:39 +00:00
throw new Error (
2020-12-18 19:27:43 +00:00
'applyGroupState: Member pending profile key did not have an addedByUserID'
2020-12-01 23:45:39 +00:00
) ;
}
if ( ! isValidRole ( member . member . role ) ) {
2020-12-18 19:27:43 +00:00
throw new Error (
` applyGroupState: Member pending profile key had invalid role ${ member . member . role } `
) ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
if ( member . member . profileKey ) {
newProfileKeys . push ( {
profileKey : member.member.profileKey ,
uuid : member.member.userId ,
} ) ;
}
2021-04-07 22:45:31 +00:00
2020-09-09 02:25:05 +00:00
return {
addedByUserId : invitedBy.id ,
conversationId : pending.id ,
timestamp : member.timestamp ,
2020-12-01 23:45:39 +00:00
role : member.member.role || MEMBER_ROLE_ENUM . DEFAULT ,
2020-09-09 02:25:05 +00:00
} ;
}
) ;
}
2020-12-18 19:27:43 +00:00
// membersPendingAdminApproval
if ( groupState . membersPendingAdminApproval ) {
result . pendingAdminApprovalV2 = groupState . membersPendingAdminApproval . map (
2021-06-22 14:46:42 +00:00
member = > {
2020-12-18 19:27:43 +00:00
let pending ;
if ( member . userId ) {
pending = window . ConversationController . getOrCreate (
member . userId ,
2021-04-07 22:45:31 +00:00
'private'
2020-12-18 19:27:43 +00:00
) ;
} else {
throw new Error (
'applyGroupState: Pending admin approval did not have an associated userId'
) ;
}
return {
conversationId : pending.id ,
timestamp : member.timestamp ,
} ;
}
) ;
}
// inviteLinkPassword
const { inviteLinkPassword } = groupState ;
if ( inviteLinkPassword ) {
result . groupInviteLinkPassword = inviteLinkPassword ;
} else {
result . groupInviteLinkPassword = undefined ;
}
2021-06-02 00:24:28 +00:00
// descriptionBytes
const { descriptionBytes } = groupState ;
if ( descriptionBytes && descriptionBytes . content === 'descriptionText' ) {
result . description = descriptionBytes . descriptionText ;
} else {
result . description = undefined ;
}
2021-07-21 17:58:39 +00:00
// announcementsOnly
result . announcementsOnly = groupState . announcementsOnly ;
2021-04-07 22:45:31 +00:00
return {
newAttributes : result ,
newProfileKeys ,
} ;
2020-09-09 02:25:05 +00:00
}
2020-12-01 23:45:39 +00:00
function isValidRole ( role? : number ) : role is number {
2021-06-22 14:46:42 +00:00
const MEMBER_ROLE_ENUM = Proto . Member . Role ;
2020-09-09 02:25:05 +00:00
return (
role === MEMBER_ROLE_ENUM . ADMINISTRATOR || role === MEMBER_ROLE_ENUM . DEFAULT
) ;
}
2020-12-18 19:27:43 +00:00
function isValidAccess ( access? : number ) : access is number {
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
2020-09-09 02:25:05 +00:00
return access === ACCESS_ENUM . ADMINISTRATOR || access === ACCESS_ENUM . MEMBER ;
}
2020-12-18 19:27:43 +00:00
function isValidLinkAccess ( access? : number ) : access is number {
2021-06-22 14:46:42 +00:00
const ACCESS_ENUM = Proto . AccessControl . AccessRequired ;
2020-12-18 19:27:43 +00:00
return (
access === ACCESS_ENUM . UNKNOWN ||
access === ACCESS_ENUM . ANY ||
access === ACCESS_ENUM . ADMINISTRATOR ||
access === ACCESS_ENUM . UNSATISFIABLE
) ;
}
2021-06-22 14:46:42 +00:00
function isValidProfileKey ( buffer? : Uint8Array ) : boolean {
return Boolean ( buffer && buffer . length === 32 ) ;
2020-09-09 02:25:05 +00:00
}
2020-12-18 19:27:43 +00:00
function normalizeTimestamp (
2021-06-22 14:46:42 +00:00
timestamp : number | Long | null | undefined
) : number {
2020-12-18 19:27:43 +00:00
if ( ! timestamp ) {
2021-06-22 14:46:42 +00:00
return 0 ;
}
if ( typeof timestamp === 'number' ) {
2020-12-18 19:27:43 +00:00
return timestamp ;
}
const asNumber = timestamp . toNumber ( ) ;
const now = Date . now ( ) ;
if ( ! asNumber || asNumber > now ) {
return now ;
}
return asNumber ;
}
2021-06-22 14:46:42 +00:00
type DecryptedGroupChangeActions = {
version? : number ;
sourceUuid? : string ;
addMembers? : ReadonlyArray < {
added : DecryptedMember ;
joinFromInviteLink : boolean ;
} > ;
deleteMembers? : ReadonlyArray < {
deletedUserId : string ;
} > ;
modifyMemberRoles? : ReadonlyArray < {
userId : string ;
role : Proto.Member.Role ;
} > ;
modifyMemberProfileKeys? : ReadonlyArray < {
profileKey : Uint8Array ;
uuid : string ;
} > ;
addPendingMembers? : ReadonlyArray < {
added : DecryptedMemberPendingProfileKey ;
} > ;
deletePendingMembers? : ReadonlyArray < {
deletedUserId : string ;
} > ;
promotePendingMembers? : ReadonlyArray < {
profileKey : Uint8Array ;
uuid : string ;
} > ;
modifyTitle ? : {
title? : Proto.GroupAttributeBlob ;
} ;
modifyDisappearingMessagesTimer ? : {
timer? : Proto.GroupAttributeBlob ;
} ;
addMemberPendingAdminApprovals? : ReadonlyArray < {
added : DecryptedMemberPendingAdminApproval ;
} > ;
deleteMemberPendingAdminApprovals? : ReadonlyArray < {
deletedUserId : string ;
} > ;
promoteMemberPendingAdminApprovals? : ReadonlyArray < {
userId : string ;
role : Proto.Member.Role ;
} > ;
modifyInviteLinkPassword ? : {
inviteLinkPassword? : string ;
} ;
modifyDescription ? : {
descriptionBytes? : Proto.GroupAttributeBlob ;
} ;
2021-07-21 17:58:39 +00:00
modifyAnnouncementsOnly ? : {
announcementsOnly : boolean ;
} ;
2021-06-22 14:46:42 +00:00
} & Pick <
Proto . GroupChange . IActions ,
| 'modifyAttributesAccess'
| 'modifyMemberAccess'
| 'modifyAddFromInviteLinkAccess'
| 'modifyAvatar'
> ;
2020-12-18 19:27:43 +00:00
2020-09-09 02:25:05 +00:00
function decryptGroupChange (
2021-06-22 14:46:42 +00:00
actions : Readonly < Proto.GroupChange.IActions > ,
2020-09-09 02:25:05 +00:00
groupSecretParams : string ,
logId : string
2021-06-22 14:46:42 +00:00
) : DecryptedGroupChangeActions {
const result : DecryptedGroupChangeActions = {
version : dropNull ( actions . version ) ,
} ;
2020-09-09 02:25:05 +00:00
const clientZkGroupCipher = getClientZkGroupCipher ( groupSecretParams ) ;
2021-06-22 14:46:42 +00:00
if ( actions . sourceUuid && actions . sourceUuid . length !== 0 ) {
2020-09-09 02:25:05 +00:00
try {
2021-06-22 14:46:42 +00:00
result . sourceUuid = normalizeUuid (
decryptUuid ( clientZkGroupCipher , actions . sourceUuid ) ,
'actions.sourceUuid'
2020-09-09 02:25:05 +00:00
) ;
} catch ( error ) {
window . log . warn (
2021-06-22 14:46:42 +00:00
` decryptGroupChange/ ${ logId } : Unable to decrypt sourceUuid. ` ,
2020-09-09 02:25:05 +00:00
error && error . stack ? error.stack : error
) ;
}
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( result . sourceUuid ) ) {
2020-09-09 02:25:05 +00:00
window . log . warn (
` decryptGroupChange/ ${ logId } : Invalid sourceUuid. Clearing sourceUuid. `
) ;
2021-06-22 14:46:42 +00:00
result . sourceUuid = undefined ;
2020-09-09 02:25:05 +00:00
}
} else {
throw new Error ( 'decryptGroupChange: Missing sourceUuid' ) ;
}
2021-06-22 14:46:42 +00:00
// addMembers?: Array<GroupChange.Actions.AddMemberAction>;
result . addMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . addMembers || [ ] ) . map ( addMember = > {
2021-06-22 14:46:42 +00:00
strictAssert (
addMember . added ,
'decryptGroupChange: AddMember was missing added field!'
) ;
const decrypted = decryptMember (
clientZkGroupCipher ,
addMember . added ,
logId
) ;
if ( ! decrypted ) {
return null ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
return {
added : decrypted ,
joinFromInviteLink : Boolean ( addMember . joinFromInviteLink ) ,
} ;
2020-09-09 02:25:05 +00:00
} )
) ;
2021-06-22 14:46:42 +00:00
// deleteMembers?: Array<GroupChange.Actions.DeleteMemberAction>;
result . deleteMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . deleteMembers || [ ] ) . map ( deleteMember = > {
2021-06-22 14:46:42 +00:00
const { deletedUserId } = deleteMember ;
strictAssert (
Bytes . isNotEmpty ( deletedUserId ) ,
'decryptGroupChange: deleteMember.deletedUserId was missing'
) ;
let userId : string ;
try {
userId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , deletedUserId ) ,
'actions.deleteMembers.deletedUserId'
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt deleteMembers.deletedUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( userId ) ) {
2020-09-09 02:25:05 +00:00
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping deleteMember due to invalid userId `
) ;
return null ;
}
2021-06-22 14:46:42 +00:00
return { deletedUserId : userId } ;
2020-09-09 02:25:05 +00:00
} )
) ;
2021-06-22 14:46:42 +00:00
// modifyMemberRoles?: Array<GroupChange.Actions.ModifyMemberRoleAction>;
result . modifyMemberRoles = compact (
2020-12-18 19:27:43 +00:00
( actions . modifyMemberRoles || [ ] ) . map ( modifyMember = > {
2021-06-22 14:46:42 +00:00
strictAssert (
Bytes . isNotEmpty ( modifyMember . userId ) ,
'decryptGroupChange: modifyMemberRole.userId was missing'
) ;
let userId : string ;
try {
userId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , modifyMember . userId ) ,
'actions.modifyMemberRoles.userId'
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyMemberRole.userId. Dropping member. ` ,
error && error . stack ? error.stack : error
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
return null ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( userId ) ) {
2020-09-09 02:25:05 +00:00
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping modifyMemberRole due to invalid userId `
) ;
return null ;
}
2021-06-22 14:46:42 +00:00
const role = dropNull ( modifyMember . role ) ;
if ( ! isValidRole ( role ) ) {
2020-09-09 02:25:05 +00:00
throw new Error (
2020-12-18 19:27:43 +00:00
` decryptGroupChange: modifyMemberRole had invalid role ${ modifyMember . role } `
2020-09-09 02:25:05 +00:00
) ;
}
2021-06-22 14:46:42 +00:00
return {
role ,
userId ,
} ;
2020-09-09 02:25:05 +00:00
} )
) ;
2020-12-18 19:27:43 +00:00
// modifyMemberProfileKeys?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyMemberProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . modifyMemberProfileKeys = compact (
2020-12-18 19:27:43 +00:00
( actions . modifyMemberProfileKeys || [ ] ) . map ( modifyMemberProfileKey = > {
2021-06-22 14:46:42 +00:00
const { presentation } = modifyMemberProfileKey ;
strictAssert (
Bytes . isNotEmpty ( presentation ) ,
'decryptGroupChange: modifyMemberProfileKey.presentation was missing'
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
const decryptedPresentation = decryptProfileKeyCredentialPresentation (
clientZkGroupCipher ,
presentation
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! decryptedPresentation . uuid || ! decryptedPresentation . profileKey ) {
throw new Error (
'decryptGroupChange: uuid or profileKey missing after modifyMemberProfileKey decryption!'
) ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( decryptedPresentation . uuid ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping modifyMemberProfileKey due to invalid userId `
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
return null ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! isValidProfileKey ( decryptedPresentation . profileKey ) ) {
2020-09-09 02:25:05 +00:00
throw new Error (
2021-06-22 14:46:42 +00:00
'decryptGroupChange: modifyMemberProfileKey had invalid profileKey'
2020-09-09 02:25:05 +00:00
) ;
}
2021-06-22 14:46:42 +00:00
return decryptedPresentation ;
2020-09-09 02:25:05 +00:00
} )
) ;
2020-12-18 19:27:43 +00:00
// addPendingMembers?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.AddMemberPendingProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . addPendingMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . addPendingMembers || [ ] ) . map ( addPendingMember = > {
2021-06-22 14:46:42 +00:00
strictAssert (
addPendingMember . added ,
2020-09-11 19:37:01 +00:00
'decryptGroupChange: addPendingMember was missing added field!'
) ;
2021-06-22 14:46:42 +00:00
const decrypted = decryptMemberPendingProfileKey (
clientZkGroupCipher ,
addPendingMember . added ,
logId
) ;
if ( ! decrypted ) {
return null ;
}
return {
added : decrypted ,
} ;
2020-09-09 02:25:05 +00:00
} )
) ;
2020-12-18 19:27:43 +00:00
// deletePendingMembers?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.DeleteMemberPendingProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . deletePendingMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . deletePendingMembers || [ ] ) . map ( deletePendingMember = > {
2021-06-22 14:46:42 +00:00
const { deletedUserId } = deletePendingMember ;
strictAssert (
Bytes . isNotEmpty ( deletedUserId ) ,
'decryptGroupChange: deletePendingMembers.deletedUserId was missing'
) ;
let userId : string ;
try {
userId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , deletedUserId ) ,
'actions.deletePendingMembers.deletedUserId'
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt deletePendingMembers.deletedUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( userId ) ) {
2020-09-09 02:25:05 +00:00
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping deletePendingMember due to invalid deletedUserId `
) ;
return null ;
}
2021-06-22 14:46:42 +00:00
return {
deletedUserId : userId ,
} ;
2020-09-09 02:25:05 +00:00
} )
) ;
2020-12-18 19:27:43 +00:00
// promotePendingMembers?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.PromoteMemberPendingProfileKeyAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . promotePendingMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . promotePendingMembers || [ ] ) . map ( promotePendingMember = > {
2021-06-22 14:46:42 +00:00
const { presentation } = promotePendingMember ;
strictAssert (
Bytes . isNotEmpty ( presentation ) ,
'decryptGroupChange: promotePendingMember.presentation was missing'
) ;
const decryptedPresentation = decryptProfileKeyCredentialPresentation (
clientZkGroupCipher ,
presentation
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! decryptedPresentation . uuid || ! decryptedPresentation . profileKey ) {
throw new Error (
'decryptGroupChange: uuid or profileKey missing after promotePendingMember decryption!'
) ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( decryptedPresentation . uuid ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping modifyMemberProfileKey due to invalid userId `
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
return null ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! isValidProfileKey ( decryptedPresentation . profileKey ) ) {
2020-09-09 02:25:05 +00:00
throw new Error (
2021-06-22 14:46:42 +00:00
'decryptGroupChange: modifyMemberProfileKey had invalid profileKey'
2020-09-09 02:25:05 +00:00
) ;
}
2021-06-22 14:46:42 +00:00
return decryptedPresentation ;
2020-09-09 02:25:05 +00:00
} )
) ;
2021-06-22 14:46:42 +00:00
// modifyTitle?: GroupChange.Actions.ModifyTitleAction;
if ( actions . modifyTitle ) {
const { title } = actions . modifyTitle ;
if ( Bytes . isNotEmpty ( title ) ) {
try {
result . modifyTitle = {
title : Proto.GroupAttributeBlob.decode (
decryptGroupBlob ( clientZkGroupCipher , title )
) ,
} ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyTitle.title ` ,
error && error . stack ? error.stack : error
) ;
}
} else {
result . modifyTitle = { } ;
2020-09-09 02:25:05 +00:00
}
}
2021-06-22 14:46:42 +00:00
// modifyAvatar?: GroupChange.Actions.ModifyAvatarAction;
2020-09-09 02:25:05 +00:00
// Note: decryption happens during application of the change, on download of the avatar
2021-06-22 14:46:42 +00:00
result . modifyAvatar = actions . modifyAvatar ;
2020-09-09 02:25:05 +00:00
2020-09-11 19:37:01 +00:00
// modifyDisappearingMessagesTimer?:
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyDisappearingMessagesTimerAction;
if ( actions . modifyDisappearingMessagesTimer ) {
const { timer } = actions . modifyDisappearingMessagesTimer ;
if ( Bytes . isNotEmpty ( timer ) ) {
try {
result . modifyDisappearingMessagesTimer = {
timer : Proto.GroupAttributeBlob.decode (
decryptGroupBlob ( clientZkGroupCipher , timer )
) ,
} ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyDisappearingMessagesTimer.timer ` ,
error && error . stack ? error.stack : error
) ;
}
} else {
result . modifyDisappearingMessagesTimer = { } ;
2020-09-09 02:25:05 +00:00
}
}
2020-09-11 19:37:01 +00:00
// modifyAttributesAccess?:
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyAttributesAccessControlAction;
if ( actions . modifyAttributesAccess ) {
const attributesAccess = dropNull (
actions . modifyAttributesAccess . attributesAccess
) ;
strictAssert (
isValidAccess ( attributesAccess ) ,
2020-12-18 19:27:43 +00:00
` decryptGroupChange: modifyAttributesAccess.attributesAccess was not valid: ${ actions . modifyAttributesAccess . attributesAccess } `
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
result . modifyAttributesAccess = {
attributesAccess ,
} ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
// modifyMemberAccess?: GroupChange.Actions.ModifyMembersAccessControlAction;
if ( actions . modifyMemberAccess ) {
const membersAccess = dropNull ( actions . modifyMemberAccess . membersAccess ) ;
strictAssert (
isValidAccess ( membersAccess ) ,
2020-12-18 19:27:43 +00:00
` decryptGroupChange: modifyMemberAccess.membersAccess was not valid: ${ actions . modifyMemberAccess . membersAccess } `
) ;
2021-06-22 14:46:42 +00:00
result . modifyMemberAccess = {
membersAccess ,
} ;
2020-12-18 19:27:43 +00:00
}
// modifyAddFromInviteLinkAccess?:
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction;
if ( actions . modifyAddFromInviteLinkAccess ) {
const addFromInviteLinkAccess = dropNull (
2020-12-18 19:27:43 +00:00
actions . modifyAddFromInviteLinkAccess . addFromInviteLinkAccess
2021-06-22 14:46:42 +00:00
) ;
strictAssert (
isValidLinkAccess ( addFromInviteLinkAccess ) ,
2020-12-18 19:27:43 +00:00
` decryptGroupChange: modifyAddFromInviteLinkAccess.addFromInviteLinkAccess was not valid: ${ actions . modifyAddFromInviteLinkAccess . addFromInviteLinkAccess } `
) ;
2021-06-22 14:46:42 +00:00
result . modifyAddFromInviteLinkAccess = {
addFromInviteLinkAccess ,
} ;
2020-12-18 19:27:43 +00:00
}
// addMemberPendingAdminApprovals?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.AddMemberPendingAdminApprovalAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . addMemberPendingAdminApprovals = compact (
2020-12-18 19:27:43 +00:00
( actions . addMemberPendingAdminApprovals || [ ] ) . map (
addPendingAdminApproval = > {
2021-06-22 14:46:42 +00:00
const { added } = addPendingAdminApproval ;
strictAssert (
added ,
2020-12-18 19:27:43 +00:00
'decryptGroupChange: addPendingAdminApproval was missing added field!'
) ;
2021-06-22 14:46:42 +00:00
const decrypted = decryptMemberPendingAdminApproval (
clientZkGroupCipher ,
added ,
logId
) ;
if ( ! decrypted ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt addPendingAdminApproval.added. Dropping member. `
) ;
return null ;
}
return { added : decrypted } ;
2020-12-18 19:27:43 +00:00
}
)
) ;
// deleteMemberPendingAdminApprovals?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.DeleteMemberPendingAdminApprovalAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . deleteMemberPendingAdminApprovals = compact (
2020-12-18 19:27:43 +00:00
( actions . deleteMemberPendingAdminApprovals || [ ] ) . map (
deletePendingApproval = > {
2021-06-22 14:46:42 +00:00
const { deletedUserId } = deletePendingApproval ;
strictAssert (
Bytes . isNotEmpty ( deletedUserId ) ,
'decryptGroupChange: deletePendingApproval.deletedUserId was missing'
2020-12-18 19:27:43 +00:00
) ;
2021-06-22 14:46:42 +00:00
let userId : string ;
try {
userId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , deletedUserId ) ,
'actions.deleteMemberPendingAdminApprovals'
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt deletePendingApproval.deletedUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
if ( ! window . isValidGuid ( userId ) ) {
2020-12-18 19:27:43 +00:00
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping deletePendingApproval due to invalid deletedUserId `
) ;
return null ;
}
2021-06-22 14:46:42 +00:00
return { deletedUserId : userId } ;
2020-12-18 19:27:43 +00:00
}
)
) ;
// promoteMemberPendingAdminApprovals?: Array<
2021-06-22 14:46:42 +00:00
// GroupChange.Actions.PromoteMemberPendingAdminApprovalAction
2020-12-18 19:27:43 +00:00
// >;
2021-06-22 14:46:42 +00:00
result . promoteMemberPendingAdminApprovals = compact (
2020-12-18 19:27:43 +00:00
( actions . promoteMemberPendingAdminApprovals || [ ] ) . map (
promoteAdminApproval = > {
2021-06-22 14:46:42 +00:00
const { userId } = promoteAdminApproval ;
strictAssert (
Bytes . isNotEmpty ( userId ) ,
'decryptGroupChange: promoteAdminApproval.userId was missing'
) ;
let decryptedUserId : string ;
try {
decryptedUserId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , userId ) ,
'actions.promoteMemberPendingAdminApprovals.userId'
2020-12-18 19:27:43 +00:00
) ;
2021-06-22 14:46:42 +00:00
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt promoteAdminApproval.userId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
2020-12-18 19:27:43 +00:00
}
2021-06-22 14:46:42 +00:00
const role = dropNull ( promoteAdminApproval . role ) ;
if ( ! isValidRole ( role ) ) {
2020-12-18 19:27:43 +00:00
throw new Error (
` decryptGroupChange: promoteAdminApproval had invalid role ${ promoteAdminApproval . role } `
) ;
}
2021-06-22 14:46:42 +00:00
return { role , userId : decryptedUserId } ;
2020-12-18 19:27:43 +00:00
}
)
) ;
2021-06-22 14:46:42 +00:00
// modifyInviteLinkPassword?: GroupChange.Actions.ModifyInviteLinkPasswordAction;
if ( actions . modifyInviteLinkPassword ) {
const { inviteLinkPassword : password } = actions . modifyInviteLinkPassword ;
if ( Bytes . isNotEmpty ( password ) ) {
result . modifyInviteLinkPassword = {
inviteLinkPassword : Bytes.toBase64 ( password ) ,
} ;
} else {
result . modifyInviteLinkPassword = { } ;
}
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
// modifyDescription?: GroupChange.Actions.ModifyDescriptionAction;
if ( actions . modifyDescription ) {
const { descriptionBytes } = actions . modifyDescription ;
if ( Bytes . isNotEmpty ( descriptionBytes ) ) {
try {
result . modifyDescription = {
descriptionBytes : Proto.GroupAttributeBlob.decode (
decryptGroupBlob ( clientZkGroupCipher , descriptionBytes )
) ,
} ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyDescription.descriptionBytes ` ,
error && error . stack ? error.stack : error
) ;
}
} else {
result . modifyDescription = { } ;
2021-06-02 00:24:28 +00:00
}
}
2021-07-21 17:58:39 +00:00
// modifyAnnouncementsOnly
if ( actions . modifyAnnouncementsOnly ) {
const { announcementsOnly } = actions . modifyAnnouncementsOnly ;
result . modifyAnnouncementsOnly = {
announcementsOnly : Boolean ( announcementsOnly ) ,
} ;
}
2021-06-22 14:46:42 +00:00
return result ;
2020-09-09 02:25:05 +00:00
}
2021-01-29 22:16:48 +00:00
export function decryptGroupTitle (
2021-06-22 14:46:42 +00:00
title : Uint8Array | undefined ,
2021-01-29 22:16:48 +00:00
secretParams : string
) : string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
2021-06-22 14:46:42 +00:00
if ( ! title || ! title . length ) {
return undefined ;
}
const blob = Proto . GroupAttributeBlob . decode (
decryptGroupBlob ( clientZkGroupCipher , title )
) ;
2021-01-29 22:16:48 +00:00
2021-06-22 14:46:42 +00:00
if ( blob && blob . content === 'title' ) {
return blob . title ;
2021-01-29 22:16:48 +00:00
}
return undefined ;
}
2021-06-02 00:24:28 +00:00
export function decryptGroupDescription (
2021-06-22 14:46:42 +00:00
description : Uint8Array | undefined ,
2021-06-02 00:24:28 +00:00
secretParams : string
) : string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
2021-06-22 14:46:42 +00:00
if ( ! description || ! description . length ) {
2021-06-02 00:24:28 +00:00
return undefined ;
}
2021-06-22 14:46:42 +00:00
const blob = Proto . GroupAttributeBlob . decode (
decryptGroupBlob ( clientZkGroupCipher , description )
2021-06-02 00:24:28 +00:00
) ;
if ( blob && blob . content === 'descriptionText' ) {
return blob . descriptionText ;
}
return undefined ;
}
2021-06-22 14:46:42 +00:00
type DecryptedGroupState = {
title? : Proto.GroupAttributeBlob ;
disappearingMessagesTimer? : Proto.GroupAttributeBlob ;
accessControl ? : {
attributes : number ;
members : number ;
addFromInviteLink : number ;
} ;
version? : number ;
members? : ReadonlyArray < DecryptedMember > ;
membersPendingProfileKey? : ReadonlyArray < DecryptedMemberPendingProfileKey > ;
membersPendingAdminApproval? : ReadonlyArray < DecryptedMemberPendingAdminApproval > ;
inviteLinkPassword? : string ;
descriptionBytes? : Proto.GroupAttributeBlob ;
avatar? : string ;
2021-07-21 17:58:39 +00:00
announcementsOnly? : boolean ;
2021-06-22 14:46:42 +00:00
} ;
2020-09-09 02:25:05 +00:00
function decryptGroupState (
2021-06-22 14:46:42 +00:00
groupState : Readonly < Proto.IGroup > ,
2020-09-09 02:25:05 +00:00
groupSecretParams : string ,
logId : string
2021-06-22 14:46:42 +00:00
) : DecryptedGroupState {
2020-09-09 02:25:05 +00:00
const clientZkGroupCipher = getClientZkGroupCipher ( groupSecretParams ) ;
2021-06-22 14:46:42 +00:00
const result : DecryptedGroupState = { } ;
2020-09-09 02:25:05 +00:00
// title
2021-06-22 14:46:42 +00:00
if ( Bytes . isNotEmpty ( groupState . title ) ) {
2020-09-09 02:25:05 +00:00
try {
2021-06-22 14:46:42 +00:00
result . title = Proto . GroupAttributeBlob . decode (
decryptGroupBlob ( clientZkGroupCipher , groupState . title )
2020-09-09 02:25:05 +00:00
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupState/ ${ logId } : Unable to decrypt title. Clearing it. ` ,
error && error . stack ? error.stack : error
) ;
}
}
// avatar
// Note: decryption happens during application of the change, on download of the avatar
// disappearing message timer
2021-06-22 14:46:42 +00:00
if (
groupState . disappearingMessagesTimer &&
groupState . disappearingMessagesTimer . length
) {
2020-09-09 02:25:05 +00:00
try {
2021-06-22 14:46:42 +00:00
result . disappearingMessagesTimer = Proto . GroupAttributeBlob . decode (
2020-09-09 02:25:05 +00:00
decryptGroupBlob (
clientZkGroupCipher ,
2021-06-22 14:46:42 +00:00
groupState . disappearingMessagesTimer
2020-09-09 02:25:05 +00:00
)
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupState/ ${ logId } : Unable to decrypt disappearing message timer. Clearing it. ` ,
error && error . stack ? error.stack : error
) ;
}
}
// accessControl
2021-06-22 14:46:42 +00:00
{
const { accessControl } = groupState ;
strictAssert ( accessControl , 'No accessControl field found' ) ;
const attributes = dropNull ( accessControl . attributes ) ;
const members = dropNull ( accessControl . members ) ;
const addFromInviteLink = dropNull ( accessControl . addFromInviteLink ) ;
strictAssert (
isValidAccess ( attributes ) ,
` decryptGroupState: Access control for attributes is invalid: ${ attributes } `
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
strictAssert (
isValidAccess ( members ) ,
` decryptGroupState: Access control for members is invalid: ${ members } `
2020-12-18 19:27:43 +00:00
) ;
2021-06-22 14:46:42 +00:00
strictAssert (
isValidLinkAccess ( addFromInviteLink ) ,
` decryptGroupState: Access control for invite link is invalid: ${ addFromInviteLink } `
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
result . accessControl = {
attributes ,
members ,
addFromInviteLink ,
} ;
2020-09-09 02:25:05 +00:00
}
// version
2021-06-22 14:46:42 +00:00
strictAssert (
isNumber ( groupState . version ) ,
` decryptGroupState: Expected version to be a number; it was ${ groupState . version } `
) ;
result . version = groupState . version ;
2020-09-09 02:25:05 +00:00
// members
if ( groupState . members ) {
2021-06-22 14:46:42 +00:00
result . members = compact (
groupState . members . map ( ( member : Proto.IMember ) = >
2020-09-09 02:25:05 +00:00
decryptMember ( clientZkGroupCipher , member , logId )
)
) ;
}
2020-12-18 19:27:43 +00:00
// membersPendingProfileKey
if ( groupState . membersPendingProfileKey ) {
2021-06-22 14:46:42 +00:00
result . membersPendingProfileKey = compact (
2020-12-18 19:27:43 +00:00
groupState . membersPendingProfileKey . map (
2021-06-22 14:46:42 +00:00
( member : Proto.IMemberPendingProfileKey ) = >
2020-12-18 19:27:43 +00:00
decryptMemberPendingProfileKey ( clientZkGroupCipher , member , logId )
)
) ;
}
// membersPendingAdminApproval
if ( groupState . membersPendingAdminApproval ) {
2021-06-22 14:46:42 +00:00
result . membersPendingAdminApproval = compact (
2020-12-18 19:27:43 +00:00
groupState . membersPendingAdminApproval . map (
2021-06-22 14:46:42 +00:00
( member : Proto.IMemberPendingAdminApproval ) = >
2020-12-18 19:27:43 +00:00
decryptMemberPendingAdminApproval ( clientZkGroupCipher , member , logId )
2020-09-09 02:25:05 +00:00
)
) ;
}
2020-12-18 19:27:43 +00:00
// inviteLinkPassword
2021-06-22 14:46:42 +00:00
if ( Bytes . isNotEmpty ( groupState . inviteLinkPassword ) ) {
result . inviteLinkPassword = Bytes . toBase64 ( groupState . inviteLinkPassword ) ;
2020-12-18 19:27:43 +00:00
}
2021-06-02 00:24:28 +00:00
// descriptionBytes
2021-06-22 14:46:42 +00:00
if ( Bytes . isNotEmpty ( groupState . descriptionBytes ) ) {
2021-06-02 00:24:28 +00:00
try {
2021-06-22 14:46:42 +00:00
result . descriptionBytes = Proto . GroupAttributeBlob . decode (
decryptGroupBlob ( clientZkGroupCipher , groupState . descriptionBytes )
2021-06-02 00:24:28 +00:00
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupState/ ${ logId } : Unable to decrypt descriptionBytes. Clearing it. ` ,
error && error . stack ? error.stack : error
) ;
}
}
2021-07-21 17:58:39 +00:00
// announcementsOnly
const { announcementsOnly } = groupState ;
result . announcementsOnly = Boolean ( announcementsOnly ) ;
2021-06-22 14:46:42 +00:00
result . avatar = dropNull ( groupState . avatar ) ;
return result ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
type DecryptedMember = Readonly < {
userId : string ;
profileKey : Uint8Array ;
role : Proto.Member.Role ;
joinedAtVersion? : number ;
} > ;
2020-09-09 02:25:05 +00:00
function decryptMember (
clientZkGroupCipher : ClientZkGroupCipher ,
2021-06-22 14:46:42 +00:00
member : Readonly < Proto.IMember > ,
2020-09-09 02:25:05 +00:00
logId : string
2021-06-22 14:46:42 +00:00
) : DecryptedMember | undefined {
2020-09-09 02:25:05 +00:00
// userId
2021-06-22 14:46:42 +00:00
strictAssert (
Bytes . isNotEmpty ( member . userId ) ,
'decryptMember: Member had missing userId'
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
let userId : string ;
try {
userId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , member . userId ) ,
'decryptMember.userId'
) ;
} catch ( error ) {
window . log . warn (
` decryptMember/ ${ logId } : Unable to decrypt member userid. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return undefined ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( userId ) ) {
window . log . warn (
` decryptMember/ ${ logId } : Dropping member due to invalid userId `
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
return undefined ;
2020-09-09 02:25:05 +00:00
}
// profileKey
2021-06-22 14:46:42 +00:00
strictAssert (
Bytes . isNotEmpty ( member . profileKey ) ,
'decryptMember: Member had missing profileKey'
) ;
const profileKey = decryptProfileKey (
clientZkGroupCipher ,
member . profileKey ,
userId
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! isValidProfileKey ( profileKey ) ) {
throw new Error ( 'decryptMember: Member had invalid profileKey' ) ;
2020-09-09 02:25:05 +00:00
}
// role
2021-06-22 14:46:42 +00:00
const role = dropNull ( member . role ) ;
if ( ! isValidRole ( role ) ) {
2020-12-18 19:27:43 +00:00
throw new Error ( ` decryptMember: Member had invalid role ${ member . role } ` ) ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
return {
userId ,
profileKey ,
role ,
joinedAtVersion : dropNull ( member . joinedAtVersion ) ,
} ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
type DecryptedMemberPendingProfileKey = {
addedByUserId : string ;
timestamp : number ;
member : {
userId : string ;
profileKey? : Uint8Array ;
role? : Proto.Member.Role ;
} ;
} ;
2020-12-18 19:27:43 +00:00
function decryptMemberPendingProfileKey (
2020-09-09 02:25:05 +00:00
clientZkGroupCipher : ClientZkGroupCipher ,
2021-06-22 14:46:42 +00:00
member : Readonly < Proto.IMemberPendingProfileKey > ,
2020-09-09 02:25:05 +00:00
logId : string
2021-06-22 14:46:42 +00:00
) : DecryptedMemberPendingProfileKey | undefined {
2020-09-09 02:25:05 +00:00
// addedByUserId
2021-06-22 14:46:42 +00:00
strictAssert (
Bytes . isNotEmpty ( member . addedByUserId ) ,
'decryptMemberPendingProfileKey: Member had missing addedByUserId'
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
let addedByUserId : string ;
try {
addedByUserId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , member . addedByUserId ) ,
'decryptMemberPendingProfileKey.addedByUserId'
) ;
} catch ( error ) {
window . log . warn (
` decryptMemberPendingProfileKey/ ${ logId } : Unable to decrypt pending member addedByUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
return undefined ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( addedByUserId ) ) {
window . log . warn (
` decryptMemberPendingProfileKey/ ${ logId } : Dropping pending member due to invalid addedByUserId `
2020-12-18 19:27:43 +00:00
) ;
2021-06-22 14:46:42 +00:00
return undefined ;
2020-09-09 02:25:05 +00:00
}
// timestamp
2021-06-22 14:46:42 +00:00
const timestamp = normalizeTimestamp ( member . timestamp ) ;
2020-09-09 02:25:05 +00:00
if ( ! member . member ) {
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Dropping pending member due to missing member details `
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
return undefined ;
2020-09-09 02:25:05 +00:00
}
2021-06-22 14:46:42 +00:00
const { userId , profileKey } = member . member ;
2020-09-09 02:25:05 +00:00
// userId
2021-06-22 14:46:42 +00:00
strictAssert (
Bytes . isNotEmpty ( userId ) ,
'decryptMemberPendingProfileKey: Member had missing member.userId'
) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
let decryptedUserId : string ;
try {
decryptedUserId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , userId ) ,
'decryptMemberPendingProfileKey.member.userId'
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
} catch ( error ) {
window . log . warn (
` decryptMemberPendingProfileKey/ ${ logId } : Unable to decrypt pending member userId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return undefined ;
}
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( decryptedUserId ) ) {
window . log . warn (
` decryptMemberPendingProfileKey/ ${ logId } : Dropping pending member due to invalid member.userId `
2020-12-18 19:27:43 +00:00
) ;
2021-06-22 14:46:42 +00:00
return undefined ;
2020-09-09 02:25:05 +00:00
}
// profileKey
2021-06-22 14:46:42 +00:00
let decryptedProfileKey : Uint8Array | undefined ;
if ( Bytes . isNotEmpty ( profileKey ) ) {
2020-09-09 02:25:05 +00:00
try {
2021-06-22 14:46:42 +00:00
decryptedProfileKey = decryptProfileKey (
2020-09-09 02:25:05 +00:00
clientZkGroupCipher ,
2021-06-22 14:46:42 +00:00
profileKey ,
decryptedUserId
2020-09-09 02:25:05 +00:00
) ;
} catch ( error ) {
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Unable to decrypt pending member profileKey. Dropping profileKey. ` ,
2020-09-09 02:25:05 +00:00
error && error . stack ? error.stack : error
) ;
}
2021-06-22 14:46:42 +00:00
if ( ! isValidProfileKey ( decryptedProfileKey ) ) {
2020-09-09 02:25:05 +00:00
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Dropping profileKey, since it was invalid `
2020-09-09 02:25:05 +00:00
) ;
2021-06-22 14:46:42 +00:00
decryptedProfileKey = undefined ;
2020-09-09 02:25:05 +00:00
}
}
// role
2021-06-22 14:46:42 +00:00
const role = dropNull ( member . member . role ) ;
2020-09-09 02:25:05 +00:00
2021-06-22 14:46:42 +00:00
strictAssert (
isValidRole ( role ) ,
` decryptMemberPendingProfileKey: Member had invalid role ${ role } `
) ;
return {
addedByUserId ,
timestamp ,
member : {
userId : decryptedUserId ,
profileKey : decryptedProfileKey ,
role ,
} ,
} ;
2020-09-09 02:25:05 +00:00
}
2020-11-13 19:57:55 +00:00
2021-06-22 14:46:42 +00:00
type DecryptedMemberPendingAdminApproval = {
userId : string ;
profileKey? : Uint8Array ;
timestamp : number ;
} ;
2020-12-18 19:27:43 +00:00
function decryptMemberPendingAdminApproval (
clientZkGroupCipher : ClientZkGroupCipher ,
2021-06-22 14:46:42 +00:00
member : Readonly < Proto.IMemberPendingAdminApproval > ,
2020-12-18 19:27:43 +00:00
logId : string
2021-06-22 14:46:42 +00:00
) : DecryptedMemberPendingAdminApproval | undefined {
2020-12-18 19:27:43 +00:00
// timestamp
2021-06-22 14:46:42 +00:00
const timestamp = normalizeTimestamp ( member . timestamp ) ;
2020-12-18 19:27:43 +00:00
const { userId , profileKey } = member ;
// userId
2021-06-22 14:46:42 +00:00
strictAssert (
Bytes . isNotEmpty ( userId ) ,
'decryptMemberPendingAdminApproval: Missing userId'
) ;
2020-12-18 19:27:43 +00:00
2021-06-22 14:46:42 +00:00
let decryptedUserId : string ;
try {
decryptedUserId = normalizeUuid (
decryptUuid ( clientZkGroupCipher , userId ) ,
'decryptMemberPendingAdminApproval.userId'
) ;
} catch ( error ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Unable to decrypt pending member userId. Dropping member. ` ,
error && error . stack ? error.stack : error
2020-12-18 19:27:43 +00:00
) ;
2021-06-22 14:46:42 +00:00
return undefined ;
}
2020-12-18 19:27:43 +00:00
2021-06-22 14:46:42 +00:00
if ( ! window . isValidGuid ( decryptedUserId ) ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Invalid userId. Dropping member. `
) ;
2020-12-18 19:27:43 +00:00
2021-06-22 14:46:42 +00:00
return undefined ;
2020-12-18 19:27:43 +00:00
}
// profileKey
2021-06-22 14:46:42 +00:00
let decryptedProfileKey : Uint8Array | undefined ;
if ( Bytes . isNotEmpty ( profileKey ) ) {
2020-12-18 19:27:43 +00:00
try {
2021-06-22 14:46:42 +00:00
decryptedProfileKey = decryptProfileKey (
2020-12-18 19:27:43 +00:00
clientZkGroupCipher ,
2021-06-22 14:46:42 +00:00
profileKey ,
decryptedUserId
2020-12-18 19:27:43 +00:00
) ;
} catch ( error ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Unable to decrypt profileKey. Dropping profileKey. ` ,
error && error . stack ? error.stack : error
) ;
}
2021-06-22 14:46:42 +00:00
if ( ! isValidProfileKey ( decryptedProfileKey ) ) {
2020-12-18 19:27:43 +00:00
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Dropping profileKey, since it was invalid `
) ;
2021-06-22 14:46:42 +00:00
decryptedProfileKey = undefined ;
2020-12-18 19:27:43 +00:00
}
}
2021-06-22 14:46:42 +00:00
return {
timestamp ,
userId : decryptedUserId ,
profileKey : decryptedProfileKey ,
} ;
2020-12-18 19:27:43 +00:00
}
2020-11-13 19:57:55 +00:00
export function getMembershipList (
conversationId : string
2021-06-22 14:46:42 +00:00
) : Array < { uuid : string ; uuidCiphertext : Uint8Array } > {
2020-11-13 19:57:55 +00:00
const conversation = window . ConversationController . get ( conversationId ) ;
if ( ! conversation ) {
throw new Error ( 'getMembershipList: cannot find conversation' ) ;
}
const secretParams = conversation . get ( 'secretParams' ) ;
if ( ! secretParams ) {
throw new Error ( 'getMembershipList: no secretParams' ) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
return conversation . getMembers ( ) . map ( member = > {
const uuid = member . get ( 'uuid' ) ;
if ( ! uuid ) {
throw new Error ( 'getMembershipList: member has no UUID' ) ;
}
const uuidCiphertext = encryptUuid ( clientZkGroupCipher , uuid ) ;
return { uuid , uuidCiphertext } ;
} ) ;
}