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 ,
2020-12-01 16:42:35 +00:00
isFinite ,
2020-09-09 02:25:05 +00:00
isNumber ,
values ,
} from 'lodash' ;
2020-09-11 19:37:01 +00:00
import { ClientZkGroupCipher } from 'zkgroup' ;
import { v4 as getGuid } from 'uuid' ;
2020-09-09 02:25:05 +00:00
import {
getCredentialsForToday ,
GROUP_CREDENTIALS_KEY ,
maybeFetchNewCredentials ,
} from './services/groupCredentialFetcher' ;
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' ;
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 {
arrayBufferToBase64 ,
arrayBufferToHex ,
base64ToArrayBuffer ,
computeHash ,
2020-11-20 17:30:45 +00:00
deriveMasterKeyFromGroupV1 ,
fromEncodedBinaryToArrayBuffer ,
2021-01-29 21:19:24 +00:00
getRandomBytes ,
2020-09-09 02:25:05 +00:00
} from './Crypto' ;
import {
2021-01-29 21:19:24 +00:00
AccessRequiredEnum ,
2020-09-09 02:25:05 +00:00
GroupAttributeBlobClass ,
GroupChangeClass ,
GroupChangesClass ,
GroupClass ,
2021-01-29 22:16:48 +00:00
GroupJoinInfoClass ,
2020-09-09 02:25:05 +00:00
MemberClass ,
2020-12-18 19:27:43 +00:00
MemberPendingAdminApprovalClass ,
MemberPendingProfileKeyClass ,
ProtoBigNumberType ,
2020-09-09 02:25:05 +00:00
ProtoBinaryType ,
} from './textsecure.d' ;
2020-11-20 17:30:45 +00:00
import {
GroupCredentialsType ,
GroupLogResponseType ,
} from './textsecure/WebAPI' ;
import MessageSender , { CallbackResultType } from './textsecure/SendMessage' ;
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' ;
2020-09-09 02:25:05 +00:00
2021-01-29 22:16:48 +00:00
export { joinViaLink } from './groups/joinViaLink' ;
2020-10-06 17:06:34 +00:00
export type GroupV2AccessCreateChangeType = {
type : 'create' ;
} ;
2020-09-09 02:25:05 +00:00
export type GroupV2AccessAttributesChangeType = {
type : 'access-attributes' ;
newPrivilege : number ;
} ;
export type GroupV2AccessMembersChangeType = {
type : 'access-members' ;
newPrivilege : number ;
} ;
2020-12-18 19:27:43 +00:00
export type GroupV2AccessInviteLinkChangeType = {
type : 'access-invite-link' ;
newPrivilege : number ;
} ;
2020-09-09 02:25:05 +00:00
export type GroupV2AvatarChangeType = {
type : 'avatar' ;
removed : boolean ;
} ;
export type GroupV2TitleChangeType = {
type : 'title' ;
// Allow for null, because the title could be removed entirely
newTitle? : string ;
} ;
2020-12-18 19:27:43 +00:00
export type GroupV2GroupLinkAddChangeType = {
type : 'group-link-add' ;
privilege : number ;
} ;
export type GroupV2GroupLinkResetChangeType = {
type : 'group-link-reset' ;
} ;
export type GroupV2GroupLinkRemoveChangeType = {
type : 'group-link-remove' ;
} ;
2020-09-09 02:25:05 +00:00
// No disappearing messages timer change type - message.expirationTimerUpdate used instead
export type GroupV2MemberAddChangeType = {
type : 'member-add' ;
conversationId : string ;
} ;
export type GroupV2MemberAddFromInviteChangeType = {
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
} ;
2020-12-18 19:27:43 +00:00
export type GroupV2MemberAddFromLinkChangeType = {
type : 'member-add-from-link' ;
conversationId : string ;
} ;
export type GroupV2MemberAddFromAdminApprovalChangeType = {
type : 'member-add-from-admin-approval' ;
conversationId : string ;
} ;
2020-09-09 02:25:05 +00:00
export type GroupV2MemberPrivilegeChangeType = {
type : 'member-privilege' ;
conversationId : string ;
newPrivilege : number ;
} ;
export type GroupV2MemberRemoveChangeType = {
type : 'member-remove' ;
conversationId : string ;
} ;
export type GroupV2PendingAddOneChangeType = {
type : 'pending-add-one' ;
conversationId : string ;
} ;
export type GroupV2PendingAddManyChangeType = {
type : 'pending-add-many' ;
count : number ;
} ;
// Note: pending-remove is only used if user didn't also join the group at the same time
export type GroupV2PendingRemoveOneChangeType = {
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
export type GroupV2PendingRemoveManyChangeType = {
type : 'pending-remove-many' ;
count : number ;
inviter? : string ;
} ;
2020-12-18 19:27:43 +00:00
export type GroupV2AdminApprovalAddOneChangeType = {
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
export type GroupV2AdminApprovalRemoveOneChangeType = {
type : 'admin-approval-remove-one' ;
conversationId : string ;
inviter? : string ;
} ;
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
| GroupV2AvatarChangeType
| GroupV2GroupLinkAddChangeType
| GroupV2GroupLinkResetChangeType
| GroupV2GroupLinkRemoveChangeType
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 > ;
} ;
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 ;
} ;
// Constants
export const MASTER_KEY_LENGTH = 32 ;
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 ;
2020-12-18 19:27:43 +00:00
const SUPPORTED_CHANGE_EPOCH = 1 ;
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
) : Promise < GroupJoinInfoClass > {
const data = window . Signal . Groups . deriveGroupFields (
base64ToArrayBuffer ( masterKeyBase64 )
) ;
return makeRequestWithTemporalRetry ( {
logId : ` groupv2( ${ data . id } ) ` ,
publicParams : arrayBufferToBase64 ( data . publicParams ) ,
secretParams : arrayBufferToBase64 ( data . secretParams ) ,
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 ;
const subProto = new window . textsecure . protobuf . GroupInviteLink . GroupInviteLinkContentsV1 ( ) ;
subProto . groupMasterKey = window . Signal . Crypto . base64ToArrayBuffer ( masterKey ) ;
subProto . inviteLinkPassword = window . Signal . Crypto . base64ToArrayBuffer (
groupInviteLinkPassword
) ;
const proto = new window . textsecure . protobuf . GroupInviteLink ( ) ;
proto . v1Contents = subProto ;
const bytes = proto . toArrayBuffer ( ) ;
const hash = toWebSafeBase64 ( window . Signal . Crypto . arrayBufferToBase64 ( bytes ) ) ;
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 ) ;
const buffer = base64ToArrayBuffer ( base64 ) ;
const inviteLinkProto = window . textsecure . protobuf . GroupInviteLink . decode (
buffer
) ;
if (
inviteLinkProto . contents !== 'v1Contents' ||
! inviteLinkProto . v1Contents
) {
const error = new Error (
'parseGroupLink: Parsed proto is missing v1Contents'
) ;
error . name = LINK_VERSION_ERROR ;
throw error ;
}
if ( ! hasData ( inviteLinkProto . v1Contents . groupMasterKey ) ) {
throw new Error ( 'v1Contents.groupMasterKey had no data!' ) ;
}
if ( ! hasData ( inviteLinkProto . v1Contents . inviteLinkPassword ) ) {
throw new Error ( 'v1Contents.inviteLinkPassword had no data!' ) ;
}
const masterKey : string = inviteLinkProto . v1Contents . groupMasterKey . toString (
'base64'
) ;
if ( masterKey . length !== 44 ) {
throw new Error ( ` masterKey had unexpected length ${ masterKey . length } ` ) ;
}
const inviteLinkPassword : string = inviteLinkProto . v1Contents . inviteLinkPassword . toString (
'base64'
) ;
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
2020-11-20 17:30:45 +00:00
async function uploadAvatar ( {
logId ,
path ,
publicParams ,
secretParams ,
} : {
logId : string ;
path : string ;
publicParams : string ;
secretParams : string ;
} ) : Promise < { hash : string ; key : string } > {
try {
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
const data = await window . Signal . Migrations . readAttachmentData ( path ) ;
const hash = await computeHash ( data ) ;
const blob = new window . textsecure . protobuf . GroupAttributeBlob ( ) ;
blob . avatar = data ;
const blobPlaintext = blob . toArrayBuffer ( ) ;
const ciphertext = encryptGroupBlob ( clientZkGroupCipher , blobPlaintext ) ;
const key = await makeRequestWithTemporalRetry ( {
logId : ` uploadGroupAvatar/ ${ logId } ` ,
publicParams ,
secretParams ,
request : ( sender , options ) = >
sender . uploadGroupAvatar ( ciphertext , options ) ,
} ) ;
return {
key ,
hash ,
} ;
} catch ( error ) {
window . log . warn (
` uploadAvatar/ ${ logId } Failed to upload avatar ` ,
error . stack
) ;
throw error ;
}
}
async function buildGroupProto ( {
attributes ,
} : {
attributes : ConversationAttributesType ;
} ) : Promise < GroupClass > {
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . Member . Role ;
const ACCESS_ENUM = window . textsecure . protobuf . AccessControl . AccessRequired ;
const logId = ` groupv2( ${ attributes . id } ) ` ;
const { publicParams , secretParams } = attributes ;
if ( ! publicParams ) {
throw new Error (
` buildGroupProto/ ${ logId } : attributes were missing publicParams! `
) ;
}
if ( ! secretParams ) {
throw new Error (
` buildGroupProto/ ${ logId } : attributes were missing secretParams! `
) ;
}
const serverPublicParamsBase64 = window . getServerPublicParams ( ) ;
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
const proto = new window . textsecure . protobuf . Group ( ) ;
proto . publicKey = base64ToArrayBuffer ( publicParams ) ;
proto . version = attributes . revision || 0 ;
const titleBlob = new window . textsecure . protobuf . GroupAttributeBlob ( ) ;
titleBlob . title = attributes . name ;
const titleBlobPlaintext = titleBlob . toArrayBuffer ( ) ;
proto . title = encryptGroupBlob ( clientZkGroupCipher , titleBlobPlaintext ) ;
if ( attributes . avatar && attributes . avatar . path ) {
const { path } = attributes . avatar ;
const { key , hash } = await uploadAvatar ( {
logId ,
path ,
publicParams ,
secretParams ,
} ) ;
// eslint-disable-next-line no-param-reassign
attributes . avatar . hash = hash ;
// eslint-disable-next-line no-param-reassign
attributes . avatar . url = key ;
proto . avatar = key ;
}
if ( attributes . expireTimer ) {
const timerBlob = new window . textsecure . protobuf . GroupAttributeBlob ( ) ;
timerBlob . disappearingMessagesDuration = attributes . expireTimer ;
const timerBlobPlaintext = timerBlob . toArrayBuffer ( ) ;
proto . disappearingMessagesTimer = encryptGroupBlob (
clientZkGroupCipher ,
timerBlobPlaintext
) ;
}
const accessControl = new window . textsecure . protobuf . AccessControl ( ) ;
if ( attributes . accessControl ) {
accessControl . attributes =
attributes . accessControl . attributes || ACCESS_ENUM . MEMBER ;
accessControl . members =
attributes . accessControl . members || ACCESS_ENUM . MEMBER ;
} else {
accessControl . attributes = ACCESS_ENUM . MEMBER ;
accessControl . members = ACCESS_ENUM . MEMBER ;
}
proto . accessControl = accessControl ;
proto . members = ( attributes . membersV2 || [ ] ) . map ( item = > {
const member = new window . textsecure . protobuf . Member ( ) ;
const conversation = window . ConversationController . get ( item . conversationId ) ;
if ( ! conversation ) {
throw new Error ( ` buildGroupProto/ ${ logId } : no conversation for member! ` ) ;
}
const profileKeyCredentialBase64 = conversation . get ( 'profileKeyCredential' ) ;
if ( ! profileKeyCredentialBase64 ) {
throw new Error (
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 = > {
const pendingMember = new window . textsecure . protobuf . MemberPendingProfileKey ( ) ;
const member = new window . textsecure . protobuf . 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 ;
}
2020-09-09 02:25:05 +00:00
export function buildDisappearingMessagesTimerChange ( {
expireTimer ,
group ,
} : {
expireTimer? : number ;
group : ConversationAttributesType ;
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
const blob = new window . textsecure . protobuf . GroupAttributeBlob ( ) ;
blob . disappearingMessagesDuration = expireTimer ;
if ( ! group . secretParams ) {
throw new Error (
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const blobPlaintext = blob . toArrayBuffer ( ) ;
const blobCipherText = encryptGroupBlob ( clientZkGroupCipher , blobPlaintext ) ;
const timerAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyDisappearingMessagesTimerAction ( ) ;
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
) : GroupChangeClass . Actions {
const inviteLinkPasswordAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyInviteLinkPasswordAction ( ) ;
inviteLinkPasswordAction . inviteLinkPassword = base64ToArrayBuffer (
inviteLinkPassword
) ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyInviteLinkPassword = inviteLinkPasswordAction ;
return actions ;
}
export function buildNewGroupLinkChange (
group : ConversationAttributesType ,
inviteLinkPassword : string ,
addFromInviteLinkAccess : AccessRequiredEnum
) : GroupChangeClass . Actions {
const accessControlAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyAddFromInviteLinkAccessControlAction ( ) ;
accessControlAction . addFromInviteLinkAccess = addFromInviteLinkAccess ;
const inviteLinkPasswordAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyInviteLinkPasswordAction ( ) ;
inviteLinkPasswordAction . inviteLinkPassword = base64ToArrayBuffer (
inviteLinkPassword
) ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAddFromInviteLinkAccess = accessControlAction ;
actions . modifyInviteLinkPassword = inviteLinkPasswordAction ;
return actions ;
}
export function buildAccessControlAddFromInviteLinkChange (
group : ConversationAttributesType ,
value : AccessRequiredEnum
) : GroupChangeClass . Actions {
const accessControlAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyAddFromInviteLinkAccessControlAction ( ) ;
accessControlAction . addFromInviteLinkAccess = value ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAddFromInviteLinkAccess = accessControlAction ;
return actions ;
}
export function buildAccessControlAttributesChange (
group : ConversationAttributesType ,
value : AccessRequiredEnum
) : GroupChangeClass . Actions {
const accessControlAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyAttributesAccessControlAction ( ) ;
accessControlAction . attributesAccess = value ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
actions . version = ( group . revision || 0 ) + 1 ;
actions . modifyAttributesAccess = accessControlAction ;
return actions ;
}
export function buildAccessControlMembersChange (
group : ConversationAttributesType ,
value : AccessRequiredEnum
) : GroupChangeClass . Actions {
const accessControlAction = new window . textsecure . protobuf . GroupChange . Actions . ModifyMembersAccessControlAction ( ) ;
accessControlAction . membersAccess = value ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
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-01-29 21:19:24 +00:00
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error (
'buildDeletePendingAdminApprovalMemberChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
const deleteMemberPendingAdminApproval = new window . textsecure . protobuf . GroupChange . Actions . DeleteMemberPendingAdminApprovalAction ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error (
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
) ;
}
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
const addMemberPendingAdminApproval = new window . textsecure . protobuf . GroupChange . Actions . AddMemberPendingAdminApprovalAction ( ) ;
const presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredentialBase64 ,
group . secretParams
) ;
const added = new window . textsecure . protobuf . MemberPendingAdminApproval ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . Member . Role ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error ( 'buildAddMember: group was missing secretParams!' ) ;
}
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
const addMember = new window . textsecure . protobuf . GroupChange . Actions . AddMemberAction ( ) ;
const presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredentialBase64 ,
group . secretParams
) ;
const added = new window . textsecure . protobuf . Member ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
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 ) ;
const deletePendingMember = new window . textsecure . protobuf . GroupChange . Actions . DeleteMemberPendingProfileKeyAction ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error ( 'buildDeleteMemberChange: group was missing secretParams!' ) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
const deleteMember = new window . textsecure . protobuf . GroupChange . Actions . DeleteMemberAction ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error ( 'buildMakeAdminChange: group was missing secretParams!' ) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
const toggleAdmin = new window . textsecure . protobuf . GroupChange . Actions . ModifyMemberRoleAction ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . Member . Role ;
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error (
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
) ;
}
const clientZkGroupCipher = getClientZkGroupCipher ( group . secretParams ) ;
const uuidCipherTextBuffer = encryptUuid ( clientZkGroupCipher , uuid ) ;
const promotePendingMember = new window . textsecure . protobuf . GroupChange . Actions . PromoteMemberPendingAdminApprovalAction ( ) ;
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 ;
} ) : GroupChangeClass . Actions {
const actions = new window . textsecure . protobuf . GroupChange . Actions ( ) ;
if ( ! group . secretParams ) {
throw new Error (
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
) ;
}
const clientZkProfileCipher = getClientZkProfileOperations (
serverPublicParamsBase64
) ;
const presentation = createProfileKeyCredentialPresentation (
clientZkProfileCipher ,
profileKeyCredentialBase64 ,
group . secretParams
) ;
2020-12-18 19:27:43 +00:00
const promotePendingMember = new window . textsecure . protobuf . 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
} : {
actions : GroupChangeClass.Actions ;
group : ConversationAttributesType ;
2021-01-29 22:16:48 +00:00
inviteLinkPassword? : string ;
2020-09-09 02:25:05 +00:00
} ) : Promise < GroupChangeClass > {
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 ,
inviteLinkPassword ,
name ,
} : {
conversation : ConversationModel ;
createGroupChange : ( ) = > Promise < GroupChangeClass.Actions | undefined > ;
inviteLinkPassword? : string ;
name : string ;
} ) : Promise < void > {
const idLog = ` ${ name } / ${ conversation . idForLogging ( ) } ` ;
if ( ! conversation . isGroupV2 ( ) ) {
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
await conversation . queueJob ( async ( ) = > {
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 ,
} ) ;
const groupChangeBuffer = groupChange . toArrayBuffer ( ) ;
const groupChangeBase64 = arrayBufferToBase64 ( groupChangeBuffer ) ;
// 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' )
? window . storage . get ( 'profileKey' )
: undefined ;
const sendOptions = conversation . getSendOptions ( ) ;
const timestamp = Date . now ( ) ;
const promise = conversation . wrapSend (
window . textsecure . messaging . sendMessageToGroup (
{
groupV2 : conversation.getGroupV2Info ( {
groupChange : groupChangeBuffer ,
includePendingMembers : true ,
} ) ,
timestamp ,
profileKey ,
} ,
sendOptions
)
) ;
// 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
}
2020-09-11 19:37:01 +00:00
export function deriveGroupFields (
masterKey : ArrayBuffer
2020-11-20 17:30:45 +00:00
) : { id : ArrayBuffer ; secretParams : ArrayBuffer ; publicParams : ArrayBuffer } {
2020-09-09 02:25:05 +00:00
const secretParams = deriveGroupSecretParams ( masterKey ) ;
const publicParams = deriveGroupPublicParams ( secretParams ) ;
const id = deriveGroupID ( secretParams ) ;
return {
id ,
secretParams ,
publicParams ,
} ;
}
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 ;
}
2020-11-20 17:30:45 +00:00
// Migrating a group
export async function hasV1GroupBeenMigrated (
conversation : ConversationModel
) : Promise < boolean > {
const logId = conversation . idForLogging ( ) ;
const isGroupV1 = conversation . isGroupV1 ( ) ;
if ( ! isGroupV1 ) {
window . log . warn (
` checkForGV2Existence/ ${ logId } : Called for non-GroupV1 conversation! `
) ;
return false ;
}
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials ( ) ;
const groupId = conversation . get ( 'groupId' ) ;
if ( ! groupId ) {
throw new Error ( ` checkForGV2Existence/ ${ logId } : No groupId! ` ) ;
}
const idBuffer = fromEncodedBinaryToArrayBuffer ( groupId ) ;
const masterKeyBuffer = await deriveMasterKeyFromGroupV1 ( idBuffer ) ;
const fields = deriveGroupFields ( masterKeyBuffer ) ;
try {
await makeRequestWithTemporalRetry ( {
logId : ` getGroup/ ${ logId } ` ,
publicParams : arrayBufferToBase64 ( fields . publicParams ) ,
secretParams : arrayBufferToBase64 ( fields . secretParams ) ,
request : ( sender , options ) = > sender . getGroup ( options ) ,
} ) ;
return true ;
} catch ( error ) {
const { code } = error ;
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 > {
const isGroupV1 = conversation . isGroupV1 ( ) ;
const groupV1Id = conversation . get ( 'groupId' ) ;
const derived = conversation . get ( 'derivedGroupV2Id' ) ;
if ( ! isGroupV1 || ! groupV1Id || derived ) {
return false ;
}
const v1IdBuffer = fromEncodedBinaryToArrayBuffer ( groupV1Id ) ;
const masterKeyBuffer = await deriveMasterKeyFromGroupV1 ( v1IdBuffer ) ;
const fields = deriveGroupFields ( masterKeyBuffer ) ;
const derivedGroupV2Id = arrayBufferToBase64 ( fields . id ) ;
conversation . set ( {
derivedGroupV2Id ,
} ) ;
return true ;
}
type MigratePropsType = {
conversation : ConversationModel ;
groupChangeBase64? : string ;
newRevision? : number ;
receivedAt? : number ;
sentAt? : number ;
} ;
export async function isGroupEligibleToMigrate (
conversation : ConversationModel
) : Promise < boolean > {
if ( ! conversation . isGroupV1 ( ) ) {
return false ;
}
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
const areWeMember =
! conversation . get ( 'left' ) &&
ourConversationId &&
conversation . hasMember ( ourConversationId ) ;
if ( ! areWeMember ) {
return false ;
}
const members = conversation . get ( 'members' ) || [ ] ;
for ( let i = 0 , max = members . length ; i < max ; i += 1 ) {
const identifier = members [ i ] ;
const contact = window . ConversationController . get ( identifier ) ;
if ( ! contact ) {
return false ;
}
if ( ! contact . get ( 'uuid' ) ) {
return false ;
}
}
return true ;
}
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 ( ) ;
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . Member . Role ;
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-01-12 15:44:44 +00:00
if ( ! contact . isMe ( ) && window . GV2_MIGRATION_DISABLE_ADD ) {
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-01-12 15:44:44 +00:00
if ( ! contact . isMe ( ) && window . GV2_MIGRATION_DISABLE_INVITE ) {
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 {
await conversation . queueJob ( async ( ) = > {
const ACCESS_ENUM =
window . textsecure . protobuf . AccessControl . AccessRequired ;
const isEligible = isGroupEligibleToMigrate ( conversation ) ;
const previousGroupV1Id = conversation . get ( 'groupId' ) ;
if ( ! isEligible || ! previousGroupV1Id ) {
throw new Error (
` initiateMigrationToGroupV2: conversation is not eligible to migrate! ${ conversation . idForLogging ( ) } `
) ;
}
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer ( previousGroupV1Id ) ;
const masterKeyBuffer = await deriveMasterKeyFromGroupV1 ( groupV1IdBuffer ) ;
const fields = deriveGroupFields ( masterKeyBuffer ) ;
const groupId = arrayBufferToBase64 ( fields . id ) ;
const logId = ` groupv2( ${ groupId } ) ` ;
window . log . info (
` initiateMigrationToGroupV2/ ${ logId } : Migrating from ${ conversation . idForLogging ( ) } `
) ;
const masterKey = arrayBufferToBase64 ( masterKeyBuffer ) ;
const secretParams = arrayBufferToBase64 ( fields . secretParams ) ;
const publicParams = arrayBufferToBase64 ( fields . publicParams ) ;
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
if ( ! ourConversationId ) {
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : Couldn't fetch our own conversationId! `
) ;
}
2020-12-01 16:42:35 +00:00
const {
membersV2 ,
pendingMembersV2 ,
droppedGV2MemberIds ,
previousGroupV1Members ,
} = await getGroupMigrationMembers ( conversation ) ;
2020-11-20 17:30:45 +00:00
2020-12-01 16:42:35 +00:00
const rawSizeLimit = window . Signal . RemoteConfig . getValue (
'global.groupsv2.groupSizeHardLimit'
) ;
if ( ! rawSizeLimit ) {
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : Failed to fetch group size limit `
) ;
}
const sizeLimit = parseInt ( rawSizeLimit , 10 ) ;
if ( ! isFinite ( sizeLimit ) ) {
throw new Error (
` initiateMigrationToGroupV2/ ${ logId } : Failed to parse group size limit `
) ;
}
if ( membersV2 . length + pendingMembersV2 . length > sizeLimit ) {
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:
// - avatar
// - name
// - expireTimer
const newAttributes = {
. . . conversation . attributes ,
// Core GroupV2 info
revision : 0 ,
groupId ,
groupVersion : 2 ,
masterKey ,
publicParams ,
secretParams ,
// GroupV2 state
accessControl : {
attributes : ACCESS_ENUM.MEMBER ,
members : ACCESS_ENUM.MEMBER ,
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 ,
} ;
const groupProto = await buildGroupProto ( { attributes : newAttributes } ) ;
// Capture the CDK key provided by the server when we uploade
if ( groupProto . avatar && newAttributes . avatar ) {
newAttributes . avatar . url = groupProto . avatar ;
}
try {
await makeRequestWithTemporalRetry ( {
logId : ` createGroup/ ${ logId } ` ,
publicParams ,
secretParams ,
request : ( sender , options ) = > sender . createGroup ( groupProto , options ) ,
} ) ;
} catch ( error ) {
window . log . error (
` initiateMigrationToGroupV2/ ${ logId } : Error creating group: ` ,
error . stack
) ;
throw error ;
}
const groupChangeMessages : Array < MessageAttributesType > = [ ] ;
groupChangeMessages . push ( {
. . . generateBasicMessage ( ) ,
type : 'group-v1-migration' ,
invitedGV2Members : pendingMembersV2 ,
droppedGV2MemberIds ,
} ) ;
await updateGroup ( {
conversation ,
updates : {
newAttributes ,
groupChangeMessages ,
members : [ ] ,
} ,
} ) ;
if ( window . storage . isGroupBlocked ( previousGroupV1Id ) ) {
window . storage . addBlockedGroup ( groupId ) ;
}
// Save these most recent updates to conversation
updateConversation ( conversation . attributes ) ;
} ) ;
} catch ( error ) {
const logId = conversation . idForLogging ( ) ;
if ( ! conversation . isGroupV1 ( ) ) {
throw error ;
}
const alreadyMigrated = await hasV1GroupBeenMigrated ( conversation ) ;
if ( ! alreadyMigrated ) {
window . log . error (
` initiateMigrationToGroupV2/ ${ logId } : Group has not already been migrated, re-throwing error `
) ;
throw error ;
}
await respondToGroupV2Migration ( {
conversation ,
} ) ;
return ;
}
// We've migrated the group, now we need to let all other group members know about it
const logId = conversation . idForLogging ( ) ;
const timestamp = Date . now ( ) ;
const profileKey = conversation . get ( 'profileKey' ) ;
await wrapWithSyncMessageSend ( {
conversation ,
logId : ` sendMessageToGroup/ ${ logId } ` ,
send : async sender = >
// Minimal message to notify group members about migration
sender . sendMessageToGroup ( {
groupV2 : conversation.getGroupV2Info ( {
includePendingMembers : true ,
} ) ,
timestamp ,
profileKey : profileKey ? base64ToArrayBuffer ( profileKey ) : undefined ,
} ) ,
timestamp ,
} ) ;
}
2020-12-09 22:02:50 +00:00
export async function wrapWithSyncMessageSend ( {
2020-11-20 17:30:45 +00:00
conversation ,
logId ,
send ,
timestamp ,
} : {
conversation : ConversationModel ;
logId : string ;
send : ( sender : MessageSender ) = > Promise < CallbackResultType | undefined > ;
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 {
response = await send ( sender ) ;
} catch ( error ) {
if ( conversation . processSendResponse ( error ) ) {
response = error ;
}
}
if ( ! response ) {
throw new Error (
` wrapWithSyncMessageSend/ ${ logId } : message send didn't return result!! `
) ;
}
// Minimal implementation of sending same message to linked devices
const { dataMessage } = response ;
if ( ! dataMessage ) {
throw new Error (
` wrapWithSyncMessageSend/ ${ logId } : dataMessage was not returned by send! `
) ;
}
const ourConversationId = window . ConversationController . getOurConversationId ( ) ;
if ( ! ourConversationId ) {
throw new Error (
` wrapWithSyncMessageSend/ ${ logId } : Cannot get our conversationId! `
) ;
}
const ourConversation = window . ConversationController . get ( ourConversationId ) ;
if ( ! ourConversation ) {
throw new Error (
` wrapWithSyncMessageSend/ ${ logId } : Cannot get our conversation! `
) ;
}
await sender . sendSyncMessage (
dataMessage ,
timestamp ,
ourConversation . get ( 'e164' ) ,
ourConversation . get ( 'uuid' ) ,
null , // expirationStartTimestamp
[ ] , // sentTo
[ ] , // unidentifiedDeliveries
undefined , // isUpdate
undefined // options
) ;
}
export async function waitThenRespondToGroupV2Migration (
options : MigratePropsType
) : Promise < 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 ;
await conversation . queueJob ( async ( ) = > {
try {
// And finally try to migrate the group
await respondToGroupV2Migration ( options ) ;
} catch ( error ) {
window . log . error (
` waitThenRespondToGroupV2Migration/ ${ conversation . idForLogging ( ) } : respondToGroupV2Migration failure: ` ,
error && error . stack ? error.stack : error
) ;
}
} ) ;
}
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 > {
const isGroupV1 = conversation . isGroupV1 ( ) ;
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 ) ;
const masterKeyBuffer = await deriveMasterKeyFromGroupV1 ( groupV1IdBuffer ) ;
const fields = deriveGroupFields ( masterKeyBuffer ) ;
const groupId = arrayBufferToBase64 ( fields . id ) ;
const logId = idForLogging ( groupId ) ;
window . log . info (
` joinGroupV2ViaLinkAndMigrate/ ${ logId } : Migrating from ${ conversation . idForLogging ( ) } `
) ;
const masterKey = arrayBufferToBase64 ( masterKeyBuffer ) ;
const secretParams = arrayBufferToBase64 ( fields . secretParams ) ;
const publicParams = arrayBufferToBase64 ( fields . publicParams ) ;
// 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 ,
} ;
const groupChangeMessages = [
{
. . . 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 ( ) ;
const isGroupV1 = conversation . isGroupV1 ( ) ;
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 ) ;
const masterKeyBuffer = await deriveMasterKeyFromGroupV1 ( groupV1IdBuffer ) ;
const fields = deriveGroupFields ( masterKeyBuffer ) ;
const groupId = arrayBufferToBase64 ( 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 ( ) } `
) ;
const masterKey = arrayBufferToBase64 ( masterKeyBuffer ) ;
const secretParams = arrayBufferToBase64 ( fields . secretParams ) ;
const publicParams = arrayBufferToBase64 ( fields . publicParams ) ;
const previousGroupV1Members = conversation . get ( 'members' ) ;
const previousGroupV1MembersIds = conversation . getMemberIds ( ) ;
// Skeleton of the new group state - not useful until we add the group's server state
const attributes = {
. . . conversation . attributes ,
// Core GroupV2 info
revision : 0 ,
groupId ,
groupVersion : 2 ,
masterKey ,
publicParams ,
secretParams ,
// Capture previous GroupV1 data for future use
previousGroupV1Id ,
previousGroupV1Members ,
// Clear storage ID, since we need to start over on the storage service
storageID : undefined ,
// Clear obsolete data
derivedGroupV2Id : undefined ,
members : undefined ,
} ;
let firstGroupState : GroupClass | undefined | null ;
try {
const response : GroupLogResponseType = await makeRequestWithTemporalRetry ( {
logId : ` getGroupLog/ ${ logId } ` ,
publicParams ,
secretParams ,
request : ( sender , options ) = > sender . getGroupLog ( 0 , options ) ,
} ) ;
// Attempt to start with the first group state, only later processing future updates
firstGroupState = response ? . changes ? . groupChanges ? . [ 0 ] ? . groupState ;
} catch ( error ) {
if ( error . code === GROUP_ACCESS_DENIED_CODE ) {
window . log . info (
` respondToGroupV2Migration/ ${ logId } : Failed to access log endpoint; fetching full group state `
) ;
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
) ;
const newAttributes = await applyGroupState ( {
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 ,
members : [ ] ,
} ,
} ) ;
if ( window . storage . isGroupBlocked ( previousGroupV1Id ) ) {
window . storage . addBlockedGroup ( groupId ) ;
}
// Save these most recent updates to conversation
updateConversation ( conversation . attributes ) ;
// Finally, check for any changes to the group since its initial creation using normal
// group update codepaths.
await maybeUpdateGroup ( {
conversation ,
groupChangeBase64 ,
newRevision ,
receivedAt ,
sentAt ,
} ) ;
}
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 ;
} ;
2020-09-11 19:37:01 +00:00
export async function waitThenMaybeUpdateGroup (
options : MaybeUpdatePropsType
) : Promise < void > {
2020-09-09 02:25:05 +00:00
// First wait to process all incoming messages on the websocket
await window . waitForEmptyEventQueue ( ) ;
// Then wait to process all outstanding messages for this conversation
const { conversation } = options ;
await conversation . queueJob ( async ( ) = > {
try {
// And finally try to update the group
await maybeUpdateGroup ( options ) ;
} catch ( error ) {
window . log . error (
` waitThenMaybeUpdateGroup/ ${ conversation . idForLogging ( ) } : maybeUpdateGroup failure: ` ,
error && error . stack ? error.stack : error
) ;
}
} ) ;
}
export async function maybeUpdateGroup ( {
conversation ,
2020-09-10 21:04:45 +00:00
dropInitialJoinMessage ,
2020-09-09 02:25:05 +00:00
groupChangeBase64 ,
newRevision ,
2020-09-10 21:04:45 +00:00
receivedAt ,
sentAt ,
2020-09-16 14:22:46 +00:00
} : MaybeUpdatePropsType ) : 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 ,
} ) ;
2020-10-06 17:06:34 +00:00
await updateGroup ( { conversation , receivedAt , sentAt , updates } ) ;
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 ;
}
}
2020-10-06 17:06:34 +00:00
async function updateGroup ( {
conversation ,
receivedAt ,
sentAt ,
updates ,
} : {
conversation : ConversationModel ;
receivedAt? : number ;
sentAt? : number ;
updates : UpdatesResultType ;
} ) : Promise < void > {
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().
2020-11-20 17:30:45 +00:00
const finalReceivedAt = receivedAt || Date . now ( ) ;
const finalSentAt = sentAt || Date . now ( ) ;
// GroupV1 -> GroupV2 migration changes the groupId, and we need to update our id-based
// lookups if there's a change on that field.
const previousId = conversation . get ( 'groupId' ) ;
const idChanged = previousId && previousId !== newAttributes . groupId ;
2020-10-06 17:06:34 +00:00
conversation . set ( {
. . . newAttributes ,
// 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.
active_at :
2021-01-29 22:16:48 +00:00
( isInitialDataFetch || justJoinedGroup ) && newAttributes . name
2020-11-20 17:30:45 +00:00
? finalReceivedAt
2020-10-06 17:06:34 +00:00
: newAttributes . active_at ,
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
2020-11-20 17:30:45 +00:00
let syntheticSentAt = finalSentAt - ( 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 ,
2020-11-20 17:30:45 +00:00
received_at : finalReceivedAt ,
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 ` ) ;
const groupChangeBuffer = base64ToArrayBuffer ( groupChangeBase64 ) ;
const groupChange = window . textsecure . protobuf . 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 ;
groupChange : GroupChangeClass ;
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 ;
const youWereRemovedMessage = {
. . . 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 {
groupPublicParamsHex : arrayBufferToHex (
base64ToArrayBuffer ( groupPublicParamsBase64 )
) ,
authCredentialPresentationHex : arrayBufferToHex ( presentation ) ,
} ;
}
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 ;
const changes : Array < GroupChangesClass > = [ ] ;
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 ;
changes : Array < GroupChangesClass > ;
} ) : 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 ,
groupChange ,
2020-10-06 17:06:34 +00:00
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-02-08 21:55:21 +00:00
groupChange? : GroupChangeClass ;
2020-10-06 17:06:34 +00:00
groupState? : GroupClass ;
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 ;
let groupChangeActions : undefined | GroupChangeClass . Actions ;
let decryptedChangeActions : undefined | GroupChangeClass . Actions ;
let sourceConversationId : undefined | string ;
if ( groupChange ) {
groupChangeActions = window . textsecure . protobuf . GroupChange . Actions . decode (
groupChange . actions . toArrayBuffer ( )
) ;
if (
groupChangeActions . version &&
groupChangeActions . version > newRevision
) {
return {
newAttributes : group ,
groupChangeMessages : [ ] ,
members : [ ] ,
} ;
}
decryptedChangeActions = decryptGroupChange (
groupChangeActions ,
group . secretParams ,
logId
) ;
const { sourceUuid } = decryptedChangeActions ;
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
) ;
const newAttributes = await applyGroupState ( {
group ,
groupState : decryptedGroupState ,
sourceConversationId : isFirstFetch ? sourceConversationId : undefined ,
} ) ;
return {
newAttributes ,
groupChangeMessages : extractDiffs ( {
old : group ,
current : newAttributes ,
sourceConversationId : isFirstFetch ? sourceConversationId : undefined ,
} ) ,
members : getMembers ( decryptedGroupState ) ,
} ;
}
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 ,
members : newProfileKeys.map ( item = > ( {
. . . item ,
profileKey : arrayBufferToBase64 ( item . profileKey ) ,
} ) ) ,
} ;
}
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-02-19 18:40:41 +00:00
const oldVersion = group . version ;
const newVersion = decryptedGroupState . version ;
window . log . info (
` getCurrentGroupState/ ${ logId } : Applying full group state, from version ${ oldVersion } to ${ newVersion } . `
) ;
2020-10-06 17:06:34 +00:00
const newAttributes = await applyGroupState ( {
group ,
groupState : decryptedGroupState ,
} ) ;
2020-09-09 02:25:05 +00:00
return {
newAttributes ,
groupChangeMessages : extractDiffs ( {
old : group ,
current : newAttributes ,
dropInitialJoinMessage ,
} ) ,
members : getMembers ( decryptedGroupState ) ,
} ;
}
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 ( ) ;
2020-12-18 19:27:43 +00:00
const ACCESS_ENUM = window . textsecure . protobuf . 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' ,
} ) ;
}
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 ,
} ) ;
} ) ;
// 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 ,
flags :
window . textsecure . protobuf . DataMessage . Flags . EXPIRATION_TIMER_UPDATE ,
expirationTimerUpdate : {
expireTimer : current.expireTimer || 0 ,
sourceUuid ,
} ,
} ;
}
const result = compact ( [ message , timerNotification ] ) ;
window . log . info (
` extractDiffs/ ${ logId } complete, generated ${ result . length } change messages `
) ;
return result ;
}
function getMembers ( groupState : GroupClass ) {
if ( ! groupState . members || ! groupState . members . length ) {
return [ ] ;
}
return groupState . members . map ( ( member : MemberClass ) = > ( {
2020-09-29 23:29:11 +00:00
profileKey : arrayBufferToBase64 ( member . profileKey ) ,
2020-09-09 02:25:05 +00:00
uuid : member.userId ,
} ) ) ;
}
type GroupChangeMemberType = {
profileKey : ArrayBuffer ;
uuid : string ;
} ;
type GroupChangeResultType = {
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
} : {
actions : GroupChangeClass.Actions ;
2020-10-06 17:06:34 +00:00
group : ConversationAttributesType ;
sourceConversationId : string ;
2020-09-09 02:25:05 +00:00
} ) : Promise < GroupChangeResultType > {
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 ( ) ;
2020-09-09 02:25:05 +00:00
const ACCESS_ENUM = window . textsecure . protobuf . AccessControl . AccessRequired ;
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . 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 ;
// addMembers?: Array<GroupChangeClass.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 ,
'private' ,
{
profileKey : added.profileKey
? arrayBufferToBase64 ( added . profileKey )
: undefined ,
}
) ;
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 ,
} ) ;
}
} ) ;
// deleteMembers?: Array<GroupChangeClass.Actions.DeleteMemberAction>;
( 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. `
) ;
}
} ) ;
// modifyMemberRoles?: Array<GroupChangeClass.Actions.ModifyMemberRoleAction>;
( 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?:
// Array<GroupChangeClass.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<
// GroupChangeClass.Actions.AddMemberPendingProfileKeyAction
// >;
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<
// GroupChangeClass.Actions.DeleteMemberPendingProfileKeyAction
// >;
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<
// GroupChangeClass.Actions.PromoteMemberPendingProfileKeyAction
// >;
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 ,
'private' ,
{
profileKey : profileKey ? arrayBufferToBase64 ( profileKey ) : undefined ,
}
) ;
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 ,
} ) ;
} ) ;
// modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction;
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 ;
}
}
// modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction;
if ( actions . modifyAvatar ) {
2020-09-11 19:37:01 +00:00
const { avatar } = actions . modifyAvatar ;
2020-09-09 02:25:05 +00:00
await applyNewAvatar ( avatar , result , logId ) ;
}
2020-09-11 19:37:01 +00:00
// modifyDisappearingMessagesTimer?:
2020-12-18 19:27:43 +00:00
// GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyDisappearingMessagesTimer ) {
const disappearingMessagesTimer : GroupAttributeBlobClass | undefined =
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?:
// GroupChangeClass.Actions.ModifyAttributesAccessControlAction;
2020-09-09 02:25:05 +00:00
if ( actions . modifyAttributesAccess ) {
result . accessControl = {
. . . result . accessControl ,
attributes :
actions . modifyAttributesAccess . attributesAccess || ACCESS_ENUM . MEMBER ,
} ;
}
// modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction;
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?:
// GroupChangeClass.Actions.ModifyAddFromInviteLinkAccessControlAction;
if ( actions . modifyAddFromInviteLinkAccess ) {
result . accessControl = {
. . . result . accessControl ,
addFromInviteLink :
actions . modifyAddFromInviteLinkAccess . addFromInviteLinkAccess ||
ACCESS_ENUM . UNSATISFIABLE ,
} ;
}
// addMemberPendingAdminApprovals?: Array<
// GroupChangeClass.Actions.AddMemberPendingAdminApprovalAction
// >;
( 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<
// GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction
// >;
( 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<
// GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction
// >;
( 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 ,
} ;
}
) ;
// modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction;
if ( actions . modifyInviteLinkPassword ) {
const { inviteLinkPassword } = actions . modifyInviteLinkPassword ;
if ( inviteLinkPassword ) {
result . groupInviteLinkPassword = inviteLinkPassword ;
} else {
result . groupInviteLinkPassword = undefined ;
}
}
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!'
) ;
}
const ciphertext = await sender . getGroupAvatar ( avatarKey ) ;
const clientZkGroupCipher = getClientZkGroupCipher ( secretParamsBase64 ) ;
const plaintext = decryptGroupBlob ( clientZkGroupCipher , ciphertext ) ;
const blob = window . textsecure . protobuf . GroupAttributeBlob . decode ( plaintext ) ;
if ( blob . content !== 'avatar' ) {
throw new Error (
` decryptGroupAvatar: Returned blob had incorrect content: ${ blob . content } `
) ;
}
return blob . avatar . toArrayBuffer ( ) ;
}
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 ;
groupState : GroupClass ;
sourceConversationId? : string ;
} ) : Promise < ConversationAttributesType > {
2021-01-29 22:16:48 +00:00
const logId = idForLogging ( group . groupId ) ;
2020-09-09 02:25:05 +00:00
const ACCESS_ENUM = window . textsecure . protobuf . AccessControl . AccessRequired ;
2020-12-01 23:45:39 +00:00
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . 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 } ;
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
await applyNewAvatar ( groupState . avatar , result , logId ) ;
// 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 ) {
result . membersV2 = groupState . members . map ( ( member : MemberClass ) = > {
const conversation = window . ConversationController . getOrCreate (
member . userId ,
'private' ,
{
profileKey : member.profileKey
? arrayBufferToBase64 ( member . profileKey )
: undefined ,
}
) ;
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
}
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 (
( member : MemberPendingProfileKeyClass ) = > {
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 ,
'private' ,
{
profileKey : member.member.profileKey
? arrayBufferToBase64 ( member . member . profileKey )
: undefined ,
}
) ;
} 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
}
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 (
( member : MemberPendingAdminApprovalClass ) = > {
let pending ;
if ( member . userId ) {
pending = window . ConversationController . getOrCreate (
member . userId ,
'private' ,
{
profileKey : member.profileKey
? arrayBufferToBase64 ( member . profileKey )
: undefined ,
}
) ;
} 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 ;
}
2020-09-09 02:25:05 +00:00
return result ;
}
2020-12-01 23:45:39 +00:00
function isValidRole ( role? : number ) : role is number {
2020-09-09 02:25:05 +00:00
const MEMBER_ROLE_ENUM = window . textsecure . protobuf . Member . Role ;
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 {
2020-09-09 02:25:05 +00:00
const ACCESS_ENUM = window . textsecure . protobuf . AccessControl . AccessRequired ;
return access === ACCESS_ENUM . ADMINISTRATOR || access === ACCESS_ENUM . MEMBER ;
}
2020-12-18 19:27:43 +00:00
function isValidLinkAccess ( access? : number ) : access is number {
const ACCESS_ENUM = window . textsecure . protobuf . AccessControl . AccessRequired ;
return (
access === ACCESS_ENUM . UNKNOWN ||
access === ACCESS_ENUM . ANY ||
access === ACCESS_ENUM . ADMINISTRATOR ||
access === ACCESS_ENUM . UNSATISFIABLE
) ;
}
2020-09-09 02:25:05 +00:00
function isValidProfileKey ( buffer? : ArrayBuffer ) : boolean {
return Boolean ( buffer && buffer . byteLength === 32 ) ;
}
function hasData ( data : ProtoBinaryType ) : boolean {
return data && data . limit > 0 ;
}
2020-12-18 19:27:43 +00:00
function normalizeTimestamp (
timestamp : ProtoBigNumberType
) : number | ProtoBigNumberType {
if ( ! timestamp ) {
return timestamp ;
}
const asNumber = timestamp . toNumber ( ) ;
const now = Date . now ( ) ;
if ( ! asNumber || asNumber > now ) {
return now ;
}
return asNumber ;
}
/* eslint-disable no-param-reassign */
2020-09-09 02:25:05 +00:00
function decryptGroupChange (
2020-12-18 19:27:43 +00:00
actions : GroupChangeClass.Actions ,
2020-09-09 02:25:05 +00:00
groupSecretParams : string ,
logId : string
) : GroupChangeClass . Actions {
const clientZkGroupCipher = getClientZkGroupCipher ( groupSecretParams ) ;
if ( hasData ( actions . sourceUuid ) ) {
try {
actions . sourceUuid = decryptUuid (
clientZkGroupCipher ,
actions . sourceUuid . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt sourceUuid. Clearing sourceUuid. ` ,
error && error . stack ? error.stack : error
) ;
actions . sourceUuid = undefined ;
}
window . normalizeUuids ( actions , [ 'sourceUuid' ] , 'groups.decryptGroupChange' ) ;
if ( ! window . isValidGuid ( actions . sourceUuid ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Invalid sourceUuid. Clearing sourceUuid. `
) ;
actions . sourceUuid = undefined ;
}
} else {
throw new Error ( 'decryptGroupChange: Missing sourceUuid' ) ;
}
// addMembers?: Array<GroupChangeClass.Actions.AddMemberAction>;
actions . addMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . addMembers || [ ] ) . map ( addMember = > {
2020-09-09 02:25:05 +00:00
if ( addMember . added ) {
const decrypted = decryptMember (
clientZkGroupCipher ,
addMember . added ,
logId
) ;
if ( ! decrypted ) {
return null ;
}
addMember . added = decrypted ;
return addMember ;
}
2020-09-11 19:37:01 +00:00
throw new Error ( 'decryptGroupChange: AddMember was missing added field!' ) ;
2020-09-09 02:25:05 +00:00
} )
) ;
// deleteMembers?: Array<GroupChangeClass.Actions.DeleteMemberAction>;
actions . deleteMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . deleteMembers || [ ] ) . map ( deleteMember = > {
2020-09-09 02:25:05 +00:00
if ( hasData ( deleteMember . deletedUserId ) ) {
try {
deleteMember . deletedUserId = decryptUuid (
clientZkGroupCipher ,
deleteMember . deletedUserId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt deleteMembers.deletedUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
} else {
throw new Error (
'decryptGroupChange: deleteMember.deletedUserId was missing'
) ;
}
window . normalizeUuids (
deleteMember ,
[ 'deletedUserId' ] ,
'groups.decryptGroupChange'
) ;
if ( ! window . isValidGuid ( deleteMember . deletedUserId ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping deleteMember due to invalid userId `
) ;
return null ;
}
return deleteMember ;
} )
) ;
// modifyMemberRoles?: Array<GroupChangeClass.Actions.ModifyMemberRoleAction>;
actions . modifyMemberRoles = compact (
2020-12-18 19:27:43 +00:00
( actions . modifyMemberRoles || [ ] ) . map ( modifyMember = > {
2020-09-09 02:25:05 +00:00
if ( hasData ( modifyMember . userId ) ) {
try {
modifyMember . userId = decryptUuid (
clientZkGroupCipher ,
modifyMember . userId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyMemberRole.userId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
} else {
throw new Error (
'decryptGroupChange: modifyMemberRole.userId was missing'
) ;
}
window . normalizeUuids (
modifyMember ,
[ 'userId' ] ,
'groups.decryptGroupChange'
) ;
if ( ! window . isValidGuid ( modifyMember . userId ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping modifyMemberRole due to invalid userId `
) ;
return null ;
}
if ( ! isValidRole ( modifyMember . role ) ) {
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
) ;
}
return modifyMember ;
} )
) ;
2020-12-18 19:27:43 +00:00
// modifyMemberProfileKeys?: Array<
// GroupChangeClass.Actions.ModifyMemberProfileKeyAction
// >;
2020-09-09 02:25:05 +00:00
actions . modifyMemberProfileKeys = compact (
2020-12-18 19:27:43 +00:00
( actions . modifyMemberProfileKeys || [ ] ) . map ( modifyMemberProfileKey = > {
2020-09-09 02:25:05 +00:00
if ( hasData ( modifyMemberProfileKey . presentation ) ) {
const { profileKey , uuid } = decryptProfileKeyCredentialPresentation (
clientZkGroupCipher ,
modifyMemberProfileKey . presentation . toArrayBuffer ( )
) ;
modifyMemberProfileKey . profileKey = profileKey ;
modifyMemberProfileKey . uuid = uuid ;
if (
! modifyMemberProfileKey . uuid ||
! modifyMemberProfileKey . profileKey
) {
throw new Error (
'decryptGroupChange: uuid or profileKey missing after modifyMemberProfileKey decryption!'
) ;
}
if ( ! window . isValidGuid ( modifyMemberProfileKey . uuid ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping modifyMemberProfileKey due to invalid userId `
) ;
return null ;
}
if ( ! isValidProfileKey ( modifyMemberProfileKey . profileKey ) ) {
throw new Error (
'decryptGroupChange: modifyMemberProfileKey had invalid profileKey'
) ;
}
} else {
throw new Error (
'decryptGroupChange: modifyMemberProfileKey.presentation was missing'
) ;
}
return modifyMemberProfileKey ;
} )
) ;
2020-12-18 19:27:43 +00:00
// addPendingMembers?: Array<
// GroupChangeClass.Actions.AddMemberPendingProfileKeyAction
// >;
2020-09-09 02:25:05 +00:00
actions . addPendingMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . addPendingMembers || [ ] ) . map ( addPendingMember = > {
2020-09-09 02:25:05 +00:00
if ( addPendingMember . added ) {
2020-12-18 19:27:43 +00:00
const decrypted = decryptMemberPendingProfileKey (
2020-09-09 02:25:05 +00:00
clientZkGroupCipher ,
addPendingMember . added ,
logId
) ;
if ( ! decrypted ) {
return null ;
}
addPendingMember . added = decrypted ;
return addPendingMember ;
}
2020-09-11 19:37:01 +00:00
throw new Error (
'decryptGroupChange: addPendingMember was missing added field!'
) ;
2020-09-09 02:25:05 +00:00
} )
) ;
2020-12-18 19:27:43 +00:00
// deletePendingMembers?: Array<
// GroupChangeClass.Actions.DeleteMemberPendingProfileKeyAction
// >;
2020-09-09 02:25:05 +00:00
actions . deletePendingMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . deletePendingMembers || [ ] ) . map ( deletePendingMember = > {
2020-09-09 02:25:05 +00:00
if ( hasData ( deletePendingMember . deletedUserId ) ) {
try {
deletePendingMember . deletedUserId = decryptUuid (
clientZkGroupCipher ,
deletePendingMember . deletedUserId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt deletePendingMembers.deletedUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
} else {
throw new Error (
'decryptGroupChange: deletePendingMembers.deletedUserId was missing'
) ;
}
window . normalizeUuids (
deletePendingMember ,
[ 'deletedUserId' ] ,
'groups.decryptGroupChange'
) ;
if ( ! window . isValidGuid ( deletePendingMember . deletedUserId ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping deletePendingMember due to invalid deletedUserId `
) ;
return null ;
}
return deletePendingMember ;
} )
) ;
2020-12-18 19:27:43 +00:00
// promotePendingMembers?: Array<
// GroupChangeClass.Actions.PromoteMemberPendingProfileKeyAction
// >;
2020-09-09 02:25:05 +00:00
actions . promotePendingMembers = compact (
2020-12-18 19:27:43 +00:00
( actions . promotePendingMembers || [ ] ) . map ( promotePendingMember = > {
2020-09-09 02:25:05 +00:00
if ( hasData ( promotePendingMember . presentation ) ) {
const { profileKey , uuid } = decryptProfileKeyCredentialPresentation (
clientZkGroupCipher ,
promotePendingMember . presentation . toArrayBuffer ( )
) ;
promotePendingMember . profileKey = profileKey ;
promotePendingMember . uuid = uuid ;
if ( ! promotePendingMember . uuid || ! promotePendingMember . profileKey ) {
throw new Error (
'decryptGroupChange: uuid or profileKey missing after promotePendingMember decryption!'
) ;
}
if ( ! window . isValidGuid ( promotePendingMember . uuid ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping modifyMemberProfileKey due to invalid userId `
) ;
return null ;
}
if ( ! isValidProfileKey ( promotePendingMember . profileKey ) ) {
throw new Error (
'decryptGroupChange: modifyMemberProfileKey had invalid profileKey'
) ;
}
} else {
throw new Error (
'decryptGroupChange: promotePendingMember.presentation was missing'
) ;
}
return promotePendingMember ;
} )
) ;
// modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction;
if ( actions . modifyTitle && hasData ( actions . modifyTitle . title ) ) {
try {
actions . modifyTitle . title = window . textsecure . protobuf . GroupAttributeBlob . decode (
decryptGroupBlob (
clientZkGroupCipher ,
actions . modifyTitle . title . toArrayBuffer ( )
)
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyTitle.title ` ,
error && error . stack ? error.stack : error
) ;
actions . modifyTitle . title = undefined ;
}
} else if ( actions . modifyTitle ) {
actions . modifyTitle . title = undefined ;
}
// modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction;
// Note: decryption happens during application of the change, on download of the avatar
2020-09-11 19:37:01 +00:00
// modifyDisappearingMessagesTimer?:
// GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction;
2020-09-09 02:25:05 +00:00
if (
actions . modifyDisappearingMessagesTimer &&
hasData ( actions . modifyDisappearingMessagesTimer . timer )
) {
try {
actions . modifyDisappearingMessagesTimer . timer = window . textsecure . protobuf . GroupAttributeBlob . decode (
decryptGroupBlob (
clientZkGroupCipher ,
actions . modifyDisappearingMessagesTimer . timer . toArrayBuffer ( )
)
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt modifyDisappearingMessagesTimer.timer ` ,
error && error . stack ? error.stack : error
) ;
actions . modifyDisappearingMessagesTimer . timer = undefined ;
}
} else if ( actions . modifyDisappearingMessagesTimer ) {
actions . modifyDisappearingMessagesTimer . timer = undefined ;
}
2020-09-11 19:37:01 +00:00
// modifyAttributesAccess?:
// GroupChangeClass.Actions.ModifyAttributesAccessControlAction;
2020-09-09 02:25:05 +00:00
if (
actions . modifyAttributesAccess &&
! isValidAccess ( actions . modifyAttributesAccess . attributesAccess )
) {
throw new Error (
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
) ;
}
// modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction;
if (
actions . modifyMemberAccess &&
! isValidAccess ( actions . modifyMemberAccess . membersAccess )
) {
throw new Error (
2020-12-18 19:27:43 +00:00
` decryptGroupChange: modifyMemberAccess.membersAccess was not valid: ${ actions . modifyMemberAccess . membersAccess } `
) ;
}
// modifyAddFromInviteLinkAccess?:
// GroupChangeClass.Actions.ModifyAddFromInviteLinkAccessControlAction;
if (
actions . modifyAddFromInviteLinkAccess &&
! isValidLinkAccess (
actions . modifyAddFromInviteLinkAccess . addFromInviteLinkAccess
)
) {
throw new Error (
` decryptGroupChange: modifyAddFromInviteLinkAccess.addFromInviteLinkAccess was not valid: ${ actions . modifyAddFromInviteLinkAccess . addFromInviteLinkAccess } `
) ;
}
// addMemberPendingAdminApprovals?: Array<
// GroupChangeClass.Actions.AddMemberPendingAdminApprovalAction
// >;
actions . addMemberPendingAdminApprovals = compact (
( actions . addMemberPendingAdminApprovals || [ ] ) . map (
addPendingAdminApproval = > {
if ( addPendingAdminApproval . added ) {
const decrypted = decryptMemberPendingAdminApproval (
clientZkGroupCipher ,
addPendingAdminApproval . added ,
logId
) ;
if ( ! decrypted ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt addPendingAdminApproval.added. Dropping member. `
) ;
return null ;
}
addPendingAdminApproval . added = decrypted ;
return addPendingAdminApproval ;
}
throw new Error (
'decryptGroupChange: addPendingAdminApproval was missing added field!'
) ;
}
)
) ;
// deleteMemberPendingAdminApprovals?: Array<
// GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction
// >;
actions . deleteMemberPendingAdminApprovals = compact (
( actions . deleteMemberPendingAdminApprovals || [ ] ) . map (
deletePendingApproval = > {
if ( hasData ( deletePendingApproval . deletedUserId ) ) {
try {
deletePendingApproval . deletedUserId = decryptUuid (
clientZkGroupCipher ,
deletePendingApproval . deletedUserId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt deletePendingApproval.deletedUserId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
} else {
throw new Error (
'decryptGroupChange: deletePendingApproval.deletedUserId was missing'
) ;
}
window . normalizeUuids (
deletePendingApproval ,
[ 'deletedUserId' ] ,
'groups.decryptGroupChange'
) ;
if ( ! window . isValidGuid ( deletePendingApproval . deletedUserId ) ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Dropping deletePendingApproval due to invalid deletedUserId `
) ;
return null ;
}
return deletePendingApproval ;
}
)
) ;
// promoteMemberPendingAdminApprovals?: Array<
// GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction
// >;
actions . promoteMemberPendingAdminApprovals = compact (
( actions . promoteMemberPendingAdminApprovals || [ ] ) . map (
promoteAdminApproval = > {
if ( hasData ( promoteAdminApproval . userId ) ) {
try {
promoteAdminApproval . userId = decryptUuid (
clientZkGroupCipher ,
promoteAdminApproval . userId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupChange/ ${ logId } : Unable to decrypt promoteAdminApproval.userId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
} else {
throw new Error (
'decryptGroupChange: promoteAdminApproval.userId was missing'
) ;
}
if ( ! isValidRole ( promoteAdminApproval . role ) ) {
throw new Error (
` decryptGroupChange: promoteAdminApproval had invalid role ${ promoteAdminApproval . role } `
) ;
}
return promoteAdminApproval ;
}
)
) ;
// modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction;
if (
actions . modifyInviteLinkPassword &&
hasData ( actions . modifyInviteLinkPassword . inviteLinkPassword )
) {
actions . modifyInviteLinkPassword . inviteLinkPassword = actions . modifyInviteLinkPassword . inviteLinkPassword . toString (
'base64'
2020-09-09 02:25:05 +00:00
) ;
2020-12-18 19:27:43 +00:00
} else {
actions . modifyInviteLinkPassword = undefined ;
2020-09-09 02:25:05 +00:00
}
return actions ;
}
2021-01-29 22:16:48 +00:00
export function decryptGroupTitle (
title : ProtoBinaryType ,
secretParams : string
) : string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher ( secretParams ) ;
if ( hasData ( title ) ) {
const blob = window . textsecure . protobuf . GroupAttributeBlob . decode (
decryptGroupBlob ( clientZkGroupCipher , title . toArrayBuffer ( ) )
) ;
if ( blob && blob . content === 'title' ) {
return blob . title ;
}
}
return undefined ;
}
2020-09-09 02:25:05 +00:00
function decryptGroupState (
2020-12-18 19:27:43 +00:00
groupState : GroupClass ,
2020-09-09 02:25:05 +00:00
groupSecretParams : string ,
logId : string
) : GroupClass {
const clientZkGroupCipher = getClientZkGroupCipher ( groupSecretParams ) ;
// title
if ( hasData ( groupState . title ) ) {
try {
groupState . title = window . textsecure . protobuf . GroupAttributeBlob . decode (
decryptGroupBlob ( clientZkGroupCipher , groupState . title . toArrayBuffer ( ) )
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupState/ ${ logId } : Unable to decrypt title. Clearing it. ` ,
error && error . stack ? error.stack : error
) ;
groupState . title = undefined ;
}
} else {
groupState . title = undefined ;
}
// avatar
// Note: decryption happens during application of the change, on download of the avatar
// disappearing message timer
if ( hasData ( groupState . disappearingMessagesTimer ) ) {
try {
groupState . disappearingMessagesTimer = window . textsecure . protobuf . GroupAttributeBlob . decode (
decryptGroupBlob (
clientZkGroupCipher ,
groupState . disappearingMessagesTimer . toArrayBuffer ( )
)
) ;
} catch ( error ) {
window . log . warn (
` decryptGroupState/ ${ logId } : Unable to decrypt disappearing message timer. Clearing it. ` ,
error && error . stack ? error.stack : error
) ;
groupState . disappearingMessagesTimer = undefined ;
}
} else {
groupState . disappearingMessagesTimer = undefined ;
}
// accessControl
2020-12-18 19:27:43 +00:00
if ( ! isValidAccess ( groupState . accessControl ? . attributes ) ) {
2020-09-09 02:25:05 +00:00
throw new Error (
2020-12-18 19:27:43 +00:00
` decryptGroupState: Access control for attributes is invalid: ${ groupState . accessControl ? . attributes } `
2020-09-09 02:25:05 +00:00
) ;
}
2020-12-18 19:27:43 +00:00
if ( ! isValidAccess ( groupState . accessControl ? . members ) ) {
throw new Error (
` decryptGroupState: Access control for members is invalid: ${ groupState . accessControl ? . members } `
) ;
}
if ( ! isValidLinkAccess ( groupState . accessControl ? . addFromInviteLink ) ) {
2020-09-09 02:25:05 +00:00
throw new Error (
2020-12-18 19:27:43 +00:00
` decryptGroupState: Access control for invite link is invalid: ${ groupState . accessControl ? . addFromInviteLink } `
2020-09-09 02:25:05 +00:00
) ;
}
// version
if ( ! isNumber ( groupState . version ) ) {
throw new Error (
` decryptGroupState: Expected version to be a number; it was ${ groupState . version } `
) ;
}
// members
if ( groupState . members ) {
groupState . members = compact (
groupState . members . map ( ( member : MemberClass ) = >
decryptMember ( clientZkGroupCipher , member , logId )
)
) ;
}
2020-12-18 19:27:43 +00:00
// membersPendingProfileKey
if ( groupState . membersPendingProfileKey ) {
groupState . membersPendingProfileKey = compact (
groupState . membersPendingProfileKey . map (
( member : MemberPendingProfileKeyClass ) = >
decryptMemberPendingProfileKey ( clientZkGroupCipher , member , logId )
)
) ;
}
// membersPendingAdminApproval
if ( groupState . membersPendingAdminApproval ) {
groupState . membersPendingAdminApproval = compact (
groupState . membersPendingAdminApproval . map (
( member : MemberPendingAdminApprovalClass ) = >
decryptMemberPendingAdminApproval ( clientZkGroupCipher , member , logId )
2020-09-09 02:25:05 +00:00
)
) ;
}
2020-12-18 19:27:43 +00:00
// inviteLinkPassword
if ( hasData ( groupState . inviteLinkPassword ) ) {
groupState . inviteLinkPassword = groupState . inviteLinkPassword . toString (
'base64'
) ;
} else {
groupState . inviteLinkPassword = undefined ;
}
2020-09-09 02:25:05 +00:00
return groupState ;
}
function decryptMember (
clientZkGroupCipher : ClientZkGroupCipher ,
2020-12-18 19:27:43 +00:00
member : MemberClass ,
2020-09-09 02:25:05 +00:00
logId : string
) {
// userId
if ( hasData ( member . userId ) ) {
try {
member . userId = decryptUuid (
clientZkGroupCipher ,
member . userId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
` decryptMember/ ${ logId } : Unable to decrypt member userid. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
window . normalizeUuids ( member , [ 'userId' ] , 'groups.decryptMember' ) ;
if ( ! window . isValidGuid ( member . userId ) ) {
window . log . warn (
` decryptMember/ ${ logId } : Dropping member due to invalid userId `
) ;
return null ;
}
} else {
throw new Error ( 'decryptMember: Member had missing userId' ) ;
}
// profileKey
if ( hasData ( member . profileKey ) ) {
member . profileKey = decryptProfileKey (
clientZkGroupCipher ,
member . profileKey . toArrayBuffer ( ) ,
member . userId
) ;
if ( ! isValidProfileKey ( member . profileKey ) ) {
throw new Error ( 'decryptMember: Member had invalid profileKey' ) ;
}
} else {
throw new Error ( 'decryptMember: Member had missing profileKey' ) ;
}
// role
if ( ! isValidRole ( member . 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
}
return member ;
}
2020-12-18 19:27:43 +00:00
function decryptMemberPendingProfileKey (
2020-09-09 02:25:05 +00:00
clientZkGroupCipher : ClientZkGroupCipher ,
2020-12-18 19:27:43 +00:00
member : MemberPendingProfileKeyClass ,
2020-09-09 02:25:05 +00:00
logId : string
) {
// addedByUserId
if ( hasData ( member . addedByUserId ) ) {
try {
member . addedByUserId = decryptUuid (
clientZkGroupCipher ,
member . addedByUserId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Unable to decrypt pending member addedByUserId. Dropping member. ` ,
2020-09-09 02:25:05 +00:00
error && error . stack ? error.stack : error
) ;
return null ;
}
window . normalizeUuids (
member ,
[ 'addedByUserId' ] ,
2020-12-18 19:27:43 +00:00
'groups.decryptMemberPendingProfileKey'
2020-09-09 02:25:05 +00:00
) ;
if ( ! window . isValidGuid ( member . addedByUserId ) ) {
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Dropping pending member due to invalid addedByUserId `
2020-09-09 02:25:05 +00:00
) ;
return null ;
}
} else {
2020-12-18 19:27:43 +00:00
throw new Error (
'decryptMemberPendingProfileKey: Member had missing addedByUserId'
) ;
2020-09-09 02:25:05 +00:00
}
// timestamp
2020-12-18 19:27:43 +00:00
member . 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
) ;
return null ;
}
const { userId , profileKey , role } = member . member ;
// userId
if ( hasData ( userId ) ) {
try {
member . member . userId = decryptUuid (
clientZkGroupCipher ,
userId . toArrayBuffer ( )
) ;
} catch ( error ) {
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Unable to decrypt pending member userId. Dropping member. ` ,
2020-09-09 02:25:05 +00:00
error && error . stack ? error.stack : error
) ;
return null ;
}
window . normalizeUuids (
member . member ,
[ 'userId' ] ,
2020-12-18 19:27:43 +00:00
'groups.decryptMemberPendingProfileKey'
2020-09-09 02:25:05 +00:00
) ;
if ( ! window . isValidGuid ( member . member . userId ) ) {
window . log . warn (
2020-12-18 19:27:43 +00:00
` decryptMemberPendingProfileKey/ ${ logId } : Dropping pending member due to invalid member.userId `
2020-09-09 02:25:05 +00:00
) ;
return null ;
}
} else {
2020-12-18 19:27:43 +00:00
throw new Error (
'decryptMemberPendingProfileKey: Member had missing member.userId'
) ;
2020-09-09 02:25:05 +00:00
}
// profileKey
if ( hasData ( profileKey ) ) {
try {
member . member . profileKey = decryptProfileKey (
clientZkGroupCipher ,
profileKey . toArrayBuffer ( ) ,
2020-12-18 19:27:43 +00:00
member . member . userId
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
) ;
member . member . profileKey = null ;
}
if ( ! isValidProfileKey ( member . member . profileKey ) ) {
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
) ;
member . member . profileKey = null ;
}
}
// role
if ( ! isValidRole ( role ) ) {
2020-12-18 19:27:43 +00:00
throw new Error (
` decryptMemberPendingProfileKey: Member had invalid role ${ role } `
) ;
2020-09-09 02:25:05 +00:00
}
return member ;
}
2020-11-13 19:57:55 +00:00
2020-12-18 19:27:43 +00:00
function decryptMemberPendingAdminApproval (
clientZkGroupCipher : ClientZkGroupCipher ,
member : MemberPendingAdminApprovalClass ,
logId : string
) {
// timestamp
member . timestamp = normalizeTimestamp ( member . timestamp ) ;
const { userId , profileKey } = member ;
// userId
if ( hasData ( userId ) ) {
try {
member . userId = decryptUuid ( clientZkGroupCipher , userId . toArrayBuffer ( ) ) ;
} catch ( error ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Unable to decrypt pending member userId. Dropping member. ` ,
error && error . stack ? error.stack : error
) ;
return null ;
}
window . normalizeUuids (
member ,
[ 'userId' ] ,
'groups.decryptMemberPendingAdminApproval'
) ;
if ( ! window . isValidGuid ( member . userId ) ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Invalid userId. Dropping member. `
) ;
return null ;
}
} else {
throw new Error ( 'decryptMemberPendingAdminApproval: Missing userId' ) ;
}
// profileKey
if ( hasData ( profileKey ) ) {
try {
member . profileKey = decryptProfileKey (
clientZkGroupCipher ,
profileKey . toArrayBuffer ( ) ,
member . userId
) ;
} catch ( error ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Unable to decrypt profileKey. Dropping profileKey. ` ,
error && error . stack ? error.stack : error
) ;
member . profileKey = null ;
}
if ( ! isValidProfileKey ( member . profileKey ) ) {
window . log . warn (
` decryptMemberPendingAdminApproval/ ${ logId } : Dropping profileKey, since it was invalid `
) ;
member . profileKey = null ;
}
}
return member ;
}
2020-11-13 19:57:55 +00:00
export function getMembershipList (
conversationId : string
) : Array < { uuid : string ; uuidCiphertext : ArrayBuffer } > {
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 } ;
} ) ;
}