signal-desktop/ts/groups.ts
2024-02-22 13:19:50 -08:00

6950 lines
197 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
compact,
difference,
flatten,
fromPairs,
isNumber,
values,
} from 'lodash';
import Long from 'long';
import type { ClientZkGroupCipher } from '@signalapp/libsignal-client/zkgroup';
import { v4 as getGuid } from 'uuid';
import LRU from 'lru-cache';
import * as log from './logging/log';
import {
getCheckedGroupCredentialsForToday,
maybeFetchNewCredentials,
} from './services/groupCredentialFetcher';
import { storageServiceUploadJob } from './services/storage';
import dataInterface from './sql/Client';
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
import { assertDev, strictAssert } from './util/assert';
import { isMoreRecentThan } from './util/timestamp';
import { MINUTE, DurationInSeconds, SECOND } from './util/durations';
import { drop } from './util/drop';
import { dropNull } from './util/dropNull';
import type {
ConversationAttributesType,
GroupV2MemberType,
GroupV2PendingAdminApprovalType,
GroupV2PendingMemberType,
GroupV2BannedMemberType,
MessageAttributesType,
} from './model-types.d';
import {
createProfileKeyCredentialPresentation,
decodeProfileKeyCredentialPresentation,
decryptGroupBlob,
decryptProfileKey,
decryptAci,
decryptPni,
decryptServiceId,
deriveGroupID,
deriveGroupPublicParams,
deriveGroupSecretParams,
encryptGroupBlob,
encryptServiceId,
getAuthCredentialPresentation,
getClientZkAuthOperations,
getClientZkGroupCipher,
getClientZkProfileOperations,
verifyNotarySignature,
} from './util/zkgroup';
import {
computeHash,
deriveMasterKeyFromGroupV1,
getRandomBytes,
} from './Crypto';
import type {
GroupCredentialsType,
GroupLogResponseType,
} from './textsecure/WebAPI';
import { HTTPError } from './textsecure/Errors';
import type MessageSender from './textsecure/SendMessage';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from './types/Message2';
import type { ConversationModel } from './models/conversations';
import { getGroupSizeHardLimit } from './groups/limits';
import {
isGroupV1 as getIsGroupV1,
isGroupV2 as getIsGroupV2,
isGroupV2,
isMe,
} from './util/whatTypeOfConversation';
import * as Bytes from './Bytes';
import type { AvatarDataType } from './types/Avatar';
import type { ServiceIdString, AciString, PniString } from './types/ServiceId';
import {
ServiceIdKind,
isPniString,
isServiceIdString,
} from './types/ServiceId';
import { isAciString } from './util/isAciString';
import * as Errors from './types/errors';
import { SignalService as Proto } from './protobuf';
import { isNotNil } from './util/isNotNil';
import { isAccessControlEnabled } from './groups/util';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from './jobs/conversationJobQueue';
import { ReadStatus } from './messages/MessageReadStatus';
import { SeenStatus } from './MessageSeenStatus';
import { incrementMessageCounter } from './util/incrementMessageCounter';
import { sleep } from './util/sleep';
import { groupInvitesRoute } from './util/signalRoutes';
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
export { joinViaLink } from './groups/joinViaLink';
type GroupV2AccessCreateChangeType = {
type: 'create';
};
type GroupV2AccessAttributesChangeType = {
type: 'access-attributes';
newPrivilege: number;
};
type GroupV2AccessMembersChangeType = {
type: 'access-members';
newPrivilege: number;
};
type GroupV2AccessInviteLinkChangeType = {
type: 'access-invite-link';
newPrivilege: number;
};
type GroupV2AnnouncementsOnlyChangeType = {
type: 'announcements-only';
announcementsOnly: boolean;
};
type GroupV2AvatarChangeType = {
type: 'avatar';
removed: boolean;
};
type GroupV2TitleChangeType = {
type: 'title';
// Allow for null, because the title could be removed entirely
newTitle?: string;
};
type GroupV2GroupLinkAddChangeType = {
type: 'group-link-add';
privilege: number;
};
type GroupV2GroupLinkResetChangeType = {
type: 'group-link-reset';
};
type GroupV2GroupLinkRemoveChangeType = {
type: 'group-link-remove';
};
// No disappearing messages timer change type - message.expirationTimerUpdate used instead
type GroupV2MemberAddChangeType = {
type: 'member-add';
aci: AciString;
};
type GroupV2MemberAddFromInviteChangeType = {
type: 'member-add-from-invite';
aci: AciString;
inviter?: AciString;
};
type GroupV2MemberAddFromLinkChangeType = {
type: 'member-add-from-link';
aci: AciString;
};
type GroupV2MemberAddFromAdminApprovalChangeType = {
type: 'member-add-from-admin-approval';
aci: AciString;
};
type GroupV2MemberPrivilegeChangeType = {
type: 'member-privilege';
aci: AciString;
newPrivilege: number;
};
type GroupV2MemberRemoveChangeType = {
type: 'member-remove';
aci: AciString;
};
type GroupV2PendingAddOneChangeType = {
type: 'pending-add-one';
serviceId: ServiceIdString;
};
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
type GroupV2PendingRemoveOneChangeType = {
type: 'pending-remove-one';
serviceId: ServiceIdString;
inviter?: AciString;
};
// Note: pending-remove is only used if user didn't also join the group at the same time
type GroupV2PendingRemoveManyChangeType = {
type: 'pending-remove-many';
count: number;
inviter?: AciString;
};
type GroupV2AdminApprovalAddOneChangeType = {
type: 'admin-approval-add-one';
aci: AciString;
};
// Note: admin-approval-remove-one is only used if user didn't also join the group at
// the same time
type GroupV2AdminApprovalRemoveOneChangeType = {
type: 'admin-approval-remove-one';
aci: AciString;
inviter?: AciString;
};
type GroupV2AdminApprovalBounceChangeType = {
type: 'admin-approval-bounce';
times: number;
isApprovalPending: boolean;
aci: AciString;
};
export type GroupV2DescriptionChangeType = {
type: 'description';
removed?: boolean;
// Adding this field; cannot remove previous field for backwards compatibility
description?: string;
};
export type GroupV2SummaryType = {
type: 'summary';
};
export type GroupV2ChangeDetailType =
| GroupV2AccessAttributesChangeType
| GroupV2AccessCreateChangeType
| GroupV2AccessInviteLinkChangeType
| GroupV2AccessMembersChangeType
| GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType
| GroupV2AdminApprovalBounceChangeType
| GroupV2AnnouncementsOnlyChangeType
| GroupV2AvatarChangeType
| GroupV2DescriptionChangeType
| GroupV2GroupLinkAddChangeType
| GroupV2GroupLinkRemoveChangeType
| GroupV2GroupLinkResetChangeType
| GroupV2MemberAddChangeType
| GroupV2MemberAddFromAdminApprovalChangeType
| GroupV2MemberAddFromInviteChangeType
| GroupV2MemberAddFromLinkChangeType
| GroupV2MemberPrivilegeChangeType
| GroupV2MemberRemoveChangeType
| GroupV2PendingAddManyChangeType
| GroupV2PendingAddOneChangeType
| GroupV2PendingRemoveManyChangeType
| GroupV2PendingRemoveOneChangeType
| GroupV2SummaryType
| GroupV2TitleChangeType;
export type GroupV2ChangeType = {
from?: ServiceIdString;
details: Array<GroupV2ChangeDetailType>;
};
export type GroupFields = {
readonly id: Uint8Array;
readonly secretParams: Uint8Array;
readonly publicParams: Uint8Array;
};
const MAX_CACHED_GROUP_FIELDS = 100;
const groupFieldsCache = new LRU<string, GroupFields>({
max: MAX_CACHED_GROUP_FIELDS,
});
const { updateConversation } = dataInterface;
if (!isNumber(MAX_MESSAGE_SCHEMA)) {
throw new Error(
'groups.ts: Unable to capture max message schema from js/modules/types/message'
);
}
type UpdatesResultType = {
// The array of new messages to be added into the message timeline
groupChangeMessages: Array<GroupChangeMessageType>;
// The map of members in the group, and we largely just pull profile keys for each,
// because the group membership is updated in newAttributes
newProfileKeys: Map<AciString, string>;
// To be merged into the conversation model
newAttributes: ConversationAttributesType;
};
type UploadedAvatarType = {
data: Uint8Array;
hash: string;
key: string;
};
type BasicMessageType = Pick<
MessageAttributesType,
'id' | 'schemaVersion' | 'readStatus' | 'seenStatus'
>;
type GroupV2ChangeMessageType = {
type: 'group-v2-change';
} & Pick<MessageAttributesType, 'groupV2Change' | 'sourceServiceId'>;
type GroupV1MigrationMessageType = {
type: 'group-v1-migration';
} & Pick<
MessageAttributesType,
'invitedGV2Members' | 'droppedGV2MemberIds' | 'groupMigration'
>;
type TimerNotificationMessageType = {
type: 'timer-notification';
} & Pick<
MessageAttributesType,
'sourceServiceId' | 'flags' | 'expirationTimerUpdate'
>;
type GroupChangeMessageType = BasicMessageType &
(
| GroupV2ChangeMessageType
| GroupV1MigrationMessageType
| TimerNotificationMessageType
);
// Constants
export const MASTER_KEY_LENGTH = 32;
const GROUP_TITLE_MAX_ENCRYPTED_BYTES = 1024;
const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192;
export const ID_V1_LENGTH = 16;
export const ID_LENGTH = 32;
const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404;
const SUPPORTED_CHANGE_EPOCH = 5;
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
function generateBasicMessage(): BasicMessageType {
return {
id: getGuid(),
schemaVersion: MAX_MESSAGE_SCHEMA,
// this is missing most properties to fulfill this type
};
}
// Group Links
export function generateGroupInviteLinkPassword(): Uint8Array {
return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
}
// Group Links
export async function getPreJoinGroupInfo(
inviteLinkPasswordBase64: string,
masterKeyBase64: string
): Promise<Proto.GroupJoinInfo> {
const data = window.Signal.Groups.deriveGroupFields(
Bytes.fromBase64(masterKeyBase64)
);
return makeRequestWithTemporalRetry({
logId: `getPreJoinInfo/groupv2(${data.id})`,
publicParams: Bytes.toBase64(data.publicParams),
secretParams: Bytes.toBase64(data.secretParams),
request: (sender, options) =>
sender.getGroupFromLink(inviteLinkPasswordBase64, options),
});
}
export function buildGroupLink(
conversation: ConversationAttributesType
): string | undefined {
if (!isGroupV2(conversation)) {
return undefined;
}
const { masterKey, groupInviteLinkPassword } = conversation;
if (!groupInviteLinkPassword) {
return undefined;
}
strictAssert(masterKey, 'buildGroupLink requires the master key!');
const bytes = Proto.GroupInviteLink.encode({
v1Contents: {
groupMasterKey: Bytes.fromBase64(masterKey),
inviteLinkPassword: Bytes.fromBase64(groupInviteLinkPassword),
},
}).finish();
const inviteCode = toWebSafeBase64(Bytes.toBase64(bytes));
return groupInvitesRoute.toWebUrl({ inviteCode }).toString();
}
export function parseGroupLink(value: string): {
masterKey: string;
inviteLinkPassword: string;
} {
const base64 = fromWebSafeBase64(value);
const buffer = Bytes.fromBase64(base64);
const inviteLinkProto = Proto.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;
}
const {
groupMasterKey: groupMasterKeyRaw,
inviteLinkPassword: inviteLinkPasswordRaw,
} = inviteLinkProto.v1Contents;
if (!groupMasterKeyRaw || !groupMasterKeyRaw.length) {
throw new Error('v1Contents.groupMasterKey had no data!');
}
if (!inviteLinkPasswordRaw || !inviteLinkPasswordRaw.length) {
throw new Error('v1Contents.inviteLinkPassword had no data!');
}
const masterKey = Bytes.toBase64(groupMasterKeyRaw);
if (masterKey.length !== 44) {
throw new Error(`masterKey had unexpected length ${masterKey.length}`);
}
const inviteLinkPassword = Bytes.toBase64(inviteLinkPasswordRaw);
if (inviteLinkPassword.length === 0) {
throw new Error(
`inviteLinkPassword had unexpected length ${inviteLinkPassword.length}`
);
}
return { masterKey, inviteLinkPassword };
}
// Group Modifications
async function uploadAvatar(
options: {
logId: string;
publicParams: string;
secretParams: string;
} & ({ path: string } | { data: Uint8Array })
): Promise<UploadedAvatarType> {
const { logId, publicParams, secretParams } = options;
try {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
let data: Uint8Array;
if ('data' in options) {
({ data } = options);
} else {
data = await window.Signal.Migrations.readAttachmentData(options.path);
}
const hash = computeHash(data);
const blobPlaintext = Proto.GroupAttributeBlob.encode({
avatar: data,
}).finish();
const ciphertext = encryptGroupBlob(clientZkGroupCipher, blobPlaintext);
const key = await makeRequestWithTemporalRetry({
logId: `uploadGroupAvatar/${logId}`,
publicParams,
secretParams,
request: (sender, requestOptions) =>
sender.uploadGroupAvatar(ciphertext, requestOptions),
});
return {
data,
hash,
key,
};
} catch (error) {
log.warn(
`uploadAvatar/${logId} Failed to upload avatar`,
Errors.toLogFormat(error)
);
throw error;
}
}
function buildGroupTitleBuffer(
clientZkGroupCipher: ClientZkGroupCipher,
title: string
): Uint8Array {
const titleBlobPlaintext = Proto.GroupAttributeBlob.encode({
title,
}).finish();
const result = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext);
if (result.byteLength > GROUP_TITLE_MAX_ENCRYPTED_BYTES) {
throw new Error('buildGroupTitleBuffer: encrypted group title is too long');
}
return result;
}
function buildGroupDescriptionBuffer(
clientZkGroupCipher: ClientZkGroupCipher,
description: string
): Uint8Array {
const attrsBlobPlaintext = Proto.GroupAttributeBlob.encode({
descriptionText: description,
}).finish();
const result = encryptGroupBlob(clientZkGroupCipher, attrsBlobPlaintext);
if (result.byteLength > GROUP_DESC_MAX_ENCRYPTED_BYTES) {
throw new Error(
'buildGroupDescriptionBuffer: encrypted group title is too long'
);
}
return result;
}
function buildGroupProto(
attributes: Pick<
ConversationAttributesType,
| 'accessControl'
| 'expireTimer'
| 'id'
| 'membersV2'
| 'name'
| 'pendingMembersV2'
| 'publicParams'
| 'revision'
| 'secretParams'
> & {
avatarUrl?: string;
}
): Proto.Group {
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const ACCESS_ENUM = Proto.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 Proto.Group();
proto.publicKey = Bytes.fromBase64(publicParams);
proto.version = attributes.revision || 0;
if (attributes.name) {
proto.title = buildGroupTitleBuffer(clientZkGroupCipher, attributes.name);
}
if (attributes.avatarUrl) {
proto.avatar = attributes.avatarUrl;
}
if (attributes.expireTimer) {
const timerBlobPlaintext = Proto.GroupAttributeBlob.encode({
disappearingMessagesDuration: attributes.expireTimer,
}).finish();
proto.disappearingMessagesTimer = encryptGroupBlob(
clientZkGroupCipher,
timerBlobPlaintext
);
}
const accessControl = new Proto.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 Proto.Member();
const conversation = window.ConversationController.get(item.aci);
if (!conversation) {
throw new Error(`buildGroupProto/${logId}: no conversation for member!`);
}
const profileKeyCredentialBase64 = conversation.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
throw new Error(
`buildGroupProto/${logId}: member was missing profileKeyCredential!`
);
}
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
secretParams
);
member.role = item.role || MEMBER_ROLE_ENUM.DEFAULT;
member.presentation = presentation;
return member;
});
const ourAci = window.storage.user.getCheckedAci();
const ourAciCipherTextBuffer = encryptServiceId(clientZkGroupCipher, ourAci);
proto.membersPendingProfileKey = (attributes.pendingMembersV2 || []).map(
item => {
const pendingMember = new Proto.MemberPendingProfileKey();
const member = new Proto.Member();
const conversation = window.ConversationController.get(item.serviceId);
if (!conversation) {
throw new Error('buildGroupProto: no conversation for pending member!');
}
const serviceId = conversation.getCheckedServiceId(
'buildGroupProto: pending member was missing serviceId!'
);
const uuidCipherTextBuffer = encryptServiceId(
clientZkGroupCipher,
serviceId
);
member.userId = uuidCipherTextBuffer;
member.role = item.role || MEMBER_ROLE_ENUM.DEFAULT;
pendingMember.member = member;
pendingMember.timestamp = Long.fromNumber(item.timestamp);
pendingMember.addedByUserId = ourAciCipherTextBuffer;
return pendingMember;
}
);
return proto;
}
export async function buildAddMembersChange(
conversation: Pick<
ConversationAttributesType,
'bannedMembersV2' | 'id' | 'publicParams' | 'revision' | 'secretParams'
>,
conversationIds: ReadonlyArray<string>
): Promise<undefined | Proto.GroupChange.Actions> {
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const { id, publicParams, revision, secretParams } = conversation;
const logId = `groupv2(${id})`;
if (!publicParams) {
throw new Error(
`buildAddMembersChange/${logId}: attributes were missing publicParams!`
);
}
if (!secretParams) {
throw new Error(
`buildAddMembersChange/${logId}: attributes were missing secretParams!`
);
}
const newGroupVersion = (revision || 0) + 1;
const serverPublicParamsBase64 = window.getServerPublicParams();
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
const ourAci = window.storage.user.getCheckedAci();
const ourAciCipherTextBuffer = encryptServiceId(clientZkGroupCipher, ourAci);
const now = Date.now();
const addMembers: Array<Proto.GroupChange.Actions.AddMemberAction> = [];
const addPendingMembers: Array<Proto.GroupChange.Actions.AddMemberPendingProfileKeyAction> =
[];
const actions = new Proto.GroupChange.Actions();
await Promise.all(
conversationIds.map(async conversationId => {
const contact = window.ConversationController.get(conversationId);
if (!contact) {
assertDev(
false,
`buildAddMembersChange/${logId}: missing local contact, skipping`
);
return;
}
const serviceId = contact.getServiceId();
if (!serviceId) {
assertDev(
false,
`buildAddMembersChange/${logId}: missing serviceId; skipping`
);
return;
}
// Refresh our local data to be sure
if (!contact.get('profileKey') || !contact.get('profileKeyCredential')) {
await contact.getProfiles();
}
const profileKey = contact.get('profileKey');
const profileKeyCredential = contact.get('profileKeyCredential');
const member = new Proto.Member();
member.userId = encryptServiceId(clientZkGroupCipher, serviceId);
member.role = MEMBER_ROLE_ENUM.DEFAULT;
member.joinedAtVersion = newGroupVersion;
// This is inspired by [Android's equivalent code][0].
//
// [0]: https://github.com/signalapp/Signal-Android/blob/2be306867539ab1526f0e49d1aa7bd61e783d23f/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java#L152-L174
if (profileKey && profileKeyCredential) {
member.presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredential,
secretParams
);
const addMemberAction = new Proto.GroupChange.Actions.AddMemberAction();
addMemberAction.added = member;
addMemberAction.joinFromInviteLink = false;
addMembers.push(addMemberAction);
} else {
const memberPendingProfileKey = new Proto.MemberPendingProfileKey();
memberPendingProfileKey.member = member;
memberPendingProfileKey.addedByUserId = ourAciCipherTextBuffer;
memberPendingProfileKey.timestamp = Long.fromNumber(now);
const addPendingMemberAction =
new Proto.GroupChange.Actions.AddMemberPendingProfileKeyAction();
addPendingMemberAction.added = memberPendingProfileKey;
addPendingMembers.push(addPendingMemberAction);
}
const doesMemberNeedUnban = conversation.bannedMembersV2?.find(
bannedMember => bannedMember.serviceId === serviceId
);
if (doesMemberNeedUnban) {
const uuidCipherTextBuffer = encryptServiceId(
clientZkGroupCipher,
serviceId
);
const deleteMemberBannedAction =
new Proto.GroupChange.Actions.DeleteMemberBannedAction();
deleteMemberBannedAction.deletedUserId = uuidCipherTextBuffer;
actions.deleteMembersBanned = actions.deleteMembersBanned || [];
actions.deleteMembersBanned.push(deleteMemberBannedAction);
}
})
);
if (!addMembers.length && !addPendingMembers.length) {
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
// will be logged.
return undefined;
}
if (addMembers.length) {
actions.addMembers = addMembers;
}
if (addPendingMembers.length) {
actions.addPendingMembers = addPendingMembers;
}
actions.version = newGroupVersion;
return actions;
}
export async function buildUpdateAttributesChange(
conversation: Pick<
ConversationAttributesType,
'id' | 'revision' | 'publicParams' | 'secretParams'
>,
attributes: Readonly<{
avatar?: undefined | Uint8Array;
description?: string;
title?: string;
}>
): Promise<undefined | Proto.GroupChange.Actions> {
const { publicParams, secretParams, revision, id } = conversation;
const logId = `groupv2(${id})`;
if (!publicParams) {
throw new Error(
`buildUpdateAttributesChange/${logId}: attributes were missing publicParams!`
);
}
if (!secretParams) {
throw new Error(
`buildUpdateAttributesChange/${logId}: attributes were missing secretParams!`
);
}
const actions = new Proto.GroupChange.Actions();
let hasChangedSomething = false;
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
// There are three possible states here:
//
// 1. 'avatar' not in attributes: we don't want to change the avatar.
// 2. attributes.avatar === undefined: we want to clear the avatar.
// 3. attributes.avatar !== undefined: we want to update the avatar.
if ('avatar' in attributes) {
hasChangedSomething = true;
actions.modifyAvatar = new Proto.GroupChange.Actions.ModifyAvatarAction();
const { avatar } = attributes;
if (avatar) {
const uploadedAvatar = await uploadAvatar({
data: avatar,
logId,
publicParams,
secretParams,
});
actions.modifyAvatar.avatar = uploadedAvatar.key;
}
// If we don't set `actions.modifyAvatar.avatar`, it will be cleared.
}
const { title } = attributes;
if (title) {
hasChangedSomething = true;
actions.modifyTitle = new Proto.GroupChange.Actions.ModifyTitleAction();
actions.modifyTitle.title = buildGroupTitleBuffer(
clientZkGroupCipher,
title
);
}
const { description } = attributes;
if (typeof description === 'string') {
hasChangedSomething = true;
actions.modifyDescription =
new Proto.GroupChange.Actions.ModifyDescriptionAction();
actions.modifyDescription.descriptionBytes = buildGroupDescriptionBuffer(
clientZkGroupCipher,
description
);
}
if (!hasChangedSomething) {
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
// will be logged.
return undefined;
}
actions.version = (revision || 0) + 1;
return actions;
}
export function buildDisappearingMessagesTimerChange({
expireTimer,
group,
}: {
expireTimer: DurationInSeconds;
group: ConversationAttributesType;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
const blob = new Proto.GroupAttributeBlob();
blob.disappearingMessagesDuration = expireTimer;
if (!group.secretParams) {
throw new Error(
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const blobPlaintext = Proto.GroupAttributeBlob.encode(blob).finish();
const blobCipherText = encryptGroupBlob(clientZkGroupCipher, blobPlaintext);
const timerAction =
new Proto.GroupChange.Actions.ModifyDisappearingMessagesTimerAction();
timerAction.timer = blobCipherText;
actions.version = (group.revision || 0) + 1;
actions.modifyDisappearingMessagesTimer = timerAction;
return actions;
}
export function buildInviteLinkPasswordChange(
group: ConversationAttributesType,
inviteLinkPassword: string
): Proto.GroupChange.Actions {
const inviteLinkPasswordAction =
new Proto.GroupChange.Actions.ModifyInviteLinkPasswordAction();
inviteLinkPasswordAction.inviteLinkPassword =
Bytes.fromBase64(inviteLinkPassword);
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyInviteLinkPassword = inviteLinkPasswordAction;
return actions;
}
export function buildNewGroupLinkChange(
group: ConversationAttributesType,
inviteLinkPassword: string,
addFromInviteLinkAccess: AccessRequiredEnum
): Proto.GroupChange.Actions {
const accessControlAction =
new Proto.GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction();
accessControlAction.addFromInviteLinkAccess = addFromInviteLinkAccess;
const inviteLinkPasswordAction =
new Proto.GroupChange.Actions.ModifyInviteLinkPasswordAction();
inviteLinkPasswordAction.inviteLinkPassword =
Bytes.fromBase64(inviteLinkPassword);
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAddFromInviteLinkAccess = accessControlAction;
actions.modifyInviteLinkPassword = inviteLinkPasswordAction;
return actions;
}
export function buildAccessControlAddFromInviteLinkChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): Proto.GroupChange.Actions {
const accessControlAction =
new Proto.GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction();
accessControlAction.addFromInviteLinkAccess = value;
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAddFromInviteLinkAccess = accessControlAction;
return actions;
}
export function buildAnnouncementsOnlyChange(
group: ConversationAttributesType,
value: boolean
): Proto.GroupChange.Actions {
const action = new Proto.GroupChange.Actions.ModifyAnnouncementsOnlyAction();
action.announcementsOnly = value;
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAnnouncementsOnly = action;
return actions;
}
export function buildAccessControlAttributesChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): Proto.GroupChange.Actions {
const accessControlAction =
new Proto.GroupChange.Actions.ModifyAttributesAccessControlAction();
accessControlAction.attributesAccess = value;
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAttributesAccess = accessControlAction;
return actions;
}
export function buildAccessControlMembersChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): Proto.GroupChange.Actions {
const accessControlAction =
new Proto.GroupChange.Actions.ModifyMembersAccessControlAction();
accessControlAction.membersAccess = value;
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyMemberAccess = accessControlAction;
return actions;
}
export function _maybeBuildAddBannedMemberActions({
clientZkGroupCipher,
group,
ourAci,
serviceId,
}: {
clientZkGroupCipher: ClientZkGroupCipher;
group: Pick<ConversationAttributesType, 'bannedMembersV2'>;
ourAci: AciString;
serviceId: ServiceIdString;
}): Pick<
Proto.GroupChange.IActions,
'addMembersBanned' | 'deleteMembersBanned'
> {
const doesMemberNeedBan =
!group.bannedMembersV2?.find(member => member.serviceId === serviceId) &&
serviceId !== ourAci;
if (!doesMemberNeedBan) {
return {};
}
// Sort current banned members by decreasing timestamp
const sortedBannedMembers = [...(group.bannedMembersV2 ?? [])].sort(
(a, b) => {
return b.timestamp - a.timestamp;
}
);
// All members after the limit have to be deleted and are older than the
// rest of the list.
const deletedBannedMembers = sortedBannedMembers.slice(
Math.max(0, getGroupSizeHardLimit() - 1)
);
let deleteMembersBanned = null;
if (deletedBannedMembers.length > 0) {
deleteMembersBanned = deletedBannedMembers.map(bannedMember => {
const deleteMemberBannedAction =
new Proto.GroupChange.Actions.DeleteMemberBannedAction();
deleteMemberBannedAction.deletedUserId = encryptServiceId(
clientZkGroupCipher,
bannedMember.serviceId
);
return deleteMemberBannedAction;
});
}
const addMemberBannedAction =
new Proto.GroupChange.Actions.AddMemberBannedAction();
const uuidCipherTextBuffer = encryptServiceId(clientZkGroupCipher, serviceId);
addMemberBannedAction.added = new Proto.MemberBanned();
addMemberBannedAction.added.userId = uuidCipherTextBuffer;
return {
addMembersBanned: [addMemberBannedAction],
deleteMembersBanned,
};
}
// TODO AND-1101
export function buildDeletePendingAdminApprovalMemberChange({
group,
ourAci,
aci,
}: {
group: ConversationAttributesType;
ourAci: AciString;
aci: AciString;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildDeletePendingAdminApprovalMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptServiceId(clientZkGroupCipher, aci);
const deleteMemberPendingAdminApproval =
new Proto.GroupChange.Actions.DeleteMemberPendingAdminApprovalAction();
deleteMemberPendingAdminApproval.deletedUserId = uuidCipherTextBuffer;
actions.version = (group.revision || 0) + 1;
actions.deleteMemberPendingAdminApprovals = [
deleteMemberPendingAdminApproval,
];
const { addMembersBanned, deleteMembersBanned } =
_maybeBuildAddBannedMemberActions({
clientZkGroupCipher,
group,
ourAci,
serviceId: aci,
});
if (addMembersBanned) {
actions.addMembersBanned = addMembersBanned;
}
if (deleteMembersBanned) {
actions.deleteMembersBanned = deleteMembersBanned;
}
return actions;
}
export function buildAddPendingAdminApprovalMemberChange({
group,
profileKeyCredentialBase64,
serverPublicParamsBase64,
}: {
group: ConversationAttributesType;
profileKeyCredentialBase64: string;
serverPublicParamsBase64: string;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
);
}
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const addMemberPendingAdminApproval =
new Proto.GroupChange.Actions.AddMemberPendingAdminApprovalAction();
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
group.secretParams
);
const added = new Proto.MemberPendingAdminApproval();
added.presentation = presentation;
addMemberPendingAdminApproval.added = added;
actions.version = (group.revision || 0) + 1;
actions.addMemberPendingAdminApprovals = [addMemberPendingAdminApproval];
return actions;
}
export function buildAddMember({
group,
profileKeyCredentialBase64,
serverPublicParamsBase64,
serviceId,
}: {
group: ConversationAttributesType;
profileKeyCredentialBase64: string;
serverPublicParamsBase64: string;
joinFromInviteLink?: boolean;
serviceId: ServiceIdString;
}): Proto.GroupChange.Actions {
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error('buildAddMember: group was missing secretParams!');
}
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const addMember = new Proto.GroupChange.Actions.AddMemberAction();
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
group.secretParams
);
const added = new Proto.Member();
added.presentation = presentation;
added.role = MEMBER_ROLE_ENUM.DEFAULT;
addMember.added = added;
actions.version = (group.revision || 0) + 1;
actions.addMembers = [addMember];
const doesMemberNeedUnban = group.bannedMembersV2?.find(
member => member.serviceId === serviceId
);
if (doesMemberNeedUnban) {
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);
const deleteMemberBannedAction =
new Proto.GroupChange.Actions.DeleteMemberBannedAction();
deleteMemberBannedAction.deletedUserId = userIdCipherText;
actions.deleteMembersBanned = [deleteMemberBannedAction];
}
return actions;
}
export function buildDeletePendingMemberChange({
serviceIds,
group,
}: {
serviceIds: ReadonlyArray<ServiceIdString>;
group: ConversationAttributesType;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildDeletePendingMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const deletePendingMembers = serviceIds.map(serviceId => {
const uuidCipherTextBuffer = encryptServiceId(
clientZkGroupCipher,
serviceId
);
const deletePendingMember =
new Proto.GroupChange.Actions.DeleteMemberPendingProfileKeyAction();
deletePendingMember.deletedUserId = uuidCipherTextBuffer;
return deletePendingMember;
});
actions.version = (group.revision || 0) + 1;
actions.deletePendingMembers = deletePendingMembers;
return actions;
}
export function buildDeleteMemberChange({
group,
ourAci,
serviceId,
}: {
group: ConversationAttributesType;
ourAci: AciString;
serviceId: ServiceIdString;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error('buildDeleteMemberChange: group was missing secretParams!');
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptServiceId(clientZkGroupCipher, serviceId);
const deleteMember = new Proto.GroupChange.Actions.DeleteMemberAction();
deleteMember.deletedUserId = uuidCipherTextBuffer;
actions.version = (group.revision || 0) + 1;
actions.deleteMembers = [deleteMember];
const { addMembersBanned, deleteMembersBanned } =
_maybeBuildAddBannedMemberActions({
clientZkGroupCipher,
group,
ourAci,
serviceId,
});
if (addMembersBanned) {
actions.addMembersBanned = addMembersBanned;
}
if (deleteMembersBanned) {
actions.deleteMembersBanned = deleteMembersBanned;
}
return actions;
}
export function buildAddBannedMemberChange({
serviceId,
group,
}: {
serviceId: ServiceIdString;
group: ConversationAttributesType;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildAddBannedMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);
const addMemberBannedAction =
new Proto.GroupChange.Actions.AddMemberBannedAction();
addMemberBannedAction.added = new Proto.MemberBanned();
addMemberBannedAction.added.userId = userIdCipherText;
actions.addMembersBanned = [addMemberBannedAction];
if (group.pendingAdminApprovalV2?.some(item => item.aci === serviceId)) {
const deleteMemberPendingAdminApprovalAction =
new Proto.GroupChange.Actions.DeleteMemberPendingAdminApprovalAction();
deleteMemberPendingAdminApprovalAction.deletedUserId = userIdCipherText;
actions.deleteMemberPendingAdminApprovals = [
deleteMemberPendingAdminApprovalAction,
];
}
actions.version = (group.revision || 0) + 1;
return actions;
}
export function buildModifyMemberRoleChange({
serviceId,
group,
role,
}: {
serviceId: ServiceIdString;
group: ConversationAttributesType;
role: number;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error('buildMakeAdminChange: group was missing secretParams!');
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);
const toggleAdmin = new Proto.GroupChange.Actions.ModifyMemberRoleAction();
toggleAdmin.userId = userIdCipherText;
toggleAdmin.role = role;
actions.version = (group.revision || 0) + 1;
actions.modifyMemberRoles = [toggleAdmin];
return actions;
}
export function buildPromotePendingAdminApprovalMemberChange({
group,
aci,
}: {
group: ConversationAttributesType;
aci: AciString;
}): Proto.GroupChange.Actions {
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const userIdCipher = encryptServiceId(clientZkGroupCipher, aci);
const promotePendingMember =
new Proto.GroupChange.Actions.PromoteMemberPendingAdminApprovalAction();
promotePendingMember.userId = userIdCipher;
promotePendingMember.role = MEMBER_ROLE_ENUM.DEFAULT;
actions.version = (group.revision || 0) + 1;
actions.promoteMemberPendingAdminApprovals = [promotePendingMember];
return actions;
}
export type BuildPromoteMemberChangeOptionsType = Readonly<{
group: ConversationAttributesType;
serverPublicParamsBase64: string;
profileKeyCredentialBase64: string;
isPendingPniAciProfileKey: boolean;
}>;
export function buildPromoteMemberChange({
group,
profileKeyCredentialBase64,
serverPublicParamsBase64,
isPendingPniAciProfileKey = false,
}: BuildPromoteMemberChangeOptionsType): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
);
}
actions.version = (group.revision || 0) + 1;
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
group.secretParams
);
if (isPendingPniAciProfileKey) {
actions.promoteMembersPendingPniAciProfileKey = [
{
presentation,
},
];
} else {
actions.promotePendingMembers = [
{
presentation,
},
];
}
return actions;
}
async function uploadGroupChange({
actions,
group,
inviteLinkPassword,
}: {
actions: Proto.GroupChange.IActions;
group: ConversationAttributesType;
inviteLinkPassword?: string;
}): Promise<Proto.IGroupChange> {
const logId = idForLogging(group.groupId);
// 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!');
}
return makeRequestWithTemporalRetry({
logId: `uploadGroupChange/${logId}`,
publicParams: group.publicParams,
secretParams: group.secretParams,
request: (sender, options) =>
sender.modifyGroup(actions, options, inviteLinkPassword),
});
}
export async function modifyGroupV2({
conversation,
usingCredentialsFrom,
createGroupChange,
extraConversationsForSend,
inviteLinkPassword,
name,
syncMessageOnly = false,
}: {
conversation: ConversationModel;
usingCredentialsFrom: ReadonlyArray<ConversationModel>;
createGroupChange: () => Promise<Proto.GroupChange.Actions | undefined>;
extraConversationsForSend?: ReadonlyArray<string>;
inviteLinkPassword?: string;
name: string;
syncMessageOnly?: boolean;
}): Promise<void> {
const logId = `${name}/${conversation.idForLogging()}`;
if (!getIsGroupV2(conversation.attributes)) {
throw new Error(
`modifyGroupV2/${logId}: Called for non-GroupV2 conversation`
);
}
const startTime = Date.now();
const timeoutTime = startTime + MINUTE;
const MAX_ATTEMPTS = 5;
let refreshedCredentials = false;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
log.info(`modifyGroupV2/${logId}: Starting attempt ${attempt}`);
try {
// eslint-disable-next-line no-await-in-loop
await window.waitForEmptyEventQueue();
// Fetch profiles for contacts that do not have credentials (or have
// expired credentials)
{
const membersMissingCredentials = usingCredentialsFrom.filter(member =>
member.hasProfileKeyCredentialExpired()
);
const logIds = membersMissingCredentials.map(member =>
member.idForLogging()
);
if (logIds.length !== 0) {
log.info(`modifyGroupV2/${logId}: Fetching profiles for ${logIds}`);
}
// eslint-disable-next-line no-await-in-loop
await Promise.all(
membersMissingCredentials.map(member => member.getProfiles())
);
}
log.info(`modifyGroupV2/${logId}: Queuing attempt ${attempt}`);
// eslint-disable-next-line no-await-in-loop
await conversation.queueJob('modifyGroupV2', async () => {
log.info(`modifyGroupV2/${logId}: Running attempt ${attempt}`);
const actions = await createGroupChange();
if (!actions) {
log.warn(
`modifyGroupV2/${logId}: 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/${logId}: Revision mismatch - ${currentRevision} to ${newRevision}.`
);
}
// Upload. If we don't have permission, the server will return an error here.
const groupChange = await uploadGroupChange({
actions,
inviteLinkPassword,
group: conversation.attributes,
});
const groupChangeBuffer =
Proto.GroupChange.encode(groupChange).finish();
const groupChangeBase64 = Bytes.toBase64(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,
groupChange: {
base64: groupChangeBase64,
isTrusted: true,
},
newRevision,
});
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
extraConversationsForSend,
});
strictAssert(groupV2Info, 'missing groupV2Info');
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.GroupUpdate,
conversationId: conversation.id,
groupChangeBase64,
recipients: syncMessageOnly ? [] : groupV2Info.members.slice(),
revision: groupV2Info.revision,
});
});
// If we've gotten here with no error, we exit!
log.info(
`modifyGroupV2/${logId}: Update complete, with attempt ${attempt}!`
);
break;
} catch (error) {
if (error.code === 409 && Date.now() <= timeoutTime) {
log.info(
`modifyGroupV2/${logId}: Conflict while updating. Trying again...`
);
// eslint-disable-next-line no-await-in-loop
await conversation.fetchLatestGroupV2Data({ force: true });
} else if (error.code === 400 && !refreshedCredentials) {
const logIds = usingCredentialsFrom.map(member =>
member.idForLogging()
);
if (logIds.length !== 0) {
log.warn(
`modifyGroupV2/${logId}: Profile key credentials were not ` +
`up-to-date. Updating profiles for ${logIds} and retrying`
);
}
for (const member of usingCredentialsFrom) {
member.set({
profileKeyCredential: null,
profileKeyCredentialExpiration: null,
});
}
// eslint-disable-next-line no-await-in-loop
await Promise.all(
usingCredentialsFrom.map(member => member.getProfiles())
);
// Fetch credentials only once
refreshedCredentials = true;
} else if (error.code === 409) {
log.error(
`modifyGroupV2/${logId}: Conflict while updating. Timed out; not retrying.`
);
// We don't wait here because we're breaking out of the loop immediately.
void conversation.fetchLatestGroupV2Data({ force: true });
throw error;
} else {
const errorString = Errors.toLogFormat(error);
log.error(`modifyGroupV2/${logId}: Error updating: ${errorString}`);
throw error;
}
}
}
}
// Utility
export function idForLogging(groupId: string | undefined): string {
return `groupv2(${groupId})`;
}
export function deriveGroupFields(masterKey: Uint8Array): GroupFields {
if (masterKey.length !== MASTER_KEY_LENGTH) {
throw new Error(
`deriveGroupFields: masterKey had length ${masterKey.length}, ` +
`expected ${MASTER_KEY_LENGTH}`
);
}
const cacheKey = Bytes.toBase64(masterKey);
const cached = groupFieldsCache.get(cacheKey);
if (cached) {
return cached;
}
log.info('deriveGroupFields: cache miss');
const secretParams = deriveGroupSecretParams(masterKey);
const publicParams = deriveGroupPublicParams(secretParams);
const id = deriveGroupID(secretParams);
const fresh = {
id,
secretParams,
publicParams,
};
groupFieldsCache.set(cacheKey, fresh);
return fresh;
}
async function makeRequestWithTemporalRetry<T>({
logId,
publicParams,
secretParams,
request,
}: {
logId: string;
publicParams: string;
secretParams: string;
request: (sender: MessageSender, options: GroupCredentialsType) => Promise<T>;
}): Promise<T> {
const groupCredentials = getCheckedGroupCredentialsForToday(
`makeRequestWithTemporalRetry/${logId}`
);
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
`makeRequestWithTemporalRetry/${logId}: textsecure.messaging is not available!`
);
}
log.info(`makeRequestWithTemporalRetry/${logId}: starting`);
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) {
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 dropNull(response.token);
}
// Creating a group
export async function createGroupV2(
options: Readonly<{
name: string;
avatar: undefined | Uint8Array;
expireTimer: undefined | DurationInSeconds;
conversationIds: ReadonlyArray<string>;
avatars?: ReadonlyArray<AvatarDataType>;
refreshedCredentials?: boolean;
}>
): Promise<ConversationModel> {
const {
name,
avatar,
expireTimer,
conversationIds,
avatars,
refreshedCredentials = false,
} = options;
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const masterKeyBuffer = getRandomBytes(32);
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = Bytes.toBase64(fields.id);
const logId = `groupv2(${groupId})`;
const masterKey = Bytes.toBase64(masterKeyBuffer);
const secretParams = Bytes.toBase64(fields.secretParams);
const publicParams = Bytes.toBase64(fields.publicParams);
const ourAci = window.storage.user.getCheckedAci();
const ourConversation =
window.ConversationController.getOurConversationOrThrow();
if (ourConversation.hasProfileKeyCredentialExpired()) {
log.info(`createGroupV2/${logId}: fetching our own credentials`);
await ourConversation.getProfiles();
}
const membersV2: Array<GroupV2MemberType> = [
{
aci: ourAci,
role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
joinedAtVersion: 0,
},
];
const pendingMembersV2: Array<GroupV2PendingMemberType> = [];
let uploadedAvatar: undefined | UploadedAvatarType;
await Promise.all([
...conversationIds.map(async conversationId => {
const contact = window.ConversationController.get(conversationId);
if (!contact) {
assertDev(
false,
`createGroupV2/${logId}: missing local contact, skipping`
);
return;
}
const contactServiceId = contact.getServiceId();
if (!contactServiceId) {
assertDev(
false,
`createGroupV2/${logId}: missing service id; skipping`
);
return;
}
// Refresh our local data to be sure
if (contact.hasProfileKeyCredentialExpired()) {
await contact.getProfiles();
}
if (contact.get('profileKey') && contact.get('profileKeyCredential')) {
strictAssert(isAciString(contactServiceId), 'profile key without ACI');
membersV2.push({
aci: contactServiceId,
role: MEMBER_ROLE_ENUM.DEFAULT,
joinedAtVersion: 0,
});
} else {
pendingMembersV2.push({
addedByUserId: ourAci,
serviceId: contactServiceId,
timestamp: Date.now(),
role: MEMBER_ROLE_ENUM.DEFAULT,
});
}
}),
(async () => {
if (!avatar) {
return;
}
uploadedAvatar = await uploadAvatar({
data: avatar,
logId,
publicParams,
secretParams,
});
})(),
]);
if (membersV2.length + pendingMembersV2.length > getGroupSizeHardLimit()) {
throw new Error(
`createGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
);
}
const protoAndConversationAttributes = {
name,
// Core GroupV2 info
revision: 0,
publicParams,
secretParams,
// GroupV2 state
accessControl: {
attributes: ACCESS_ENUM.MEMBER,
members: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
},
membersV2,
pendingMembersV2,
};
const groupProto = await buildGroupProto({
id: groupId,
avatarUrl: uploadedAvatar?.key,
...protoAndConversationAttributes,
});
try {
await makeRequestWithTemporalRetry({
logId: `createGroupV2/${logId}`,
publicParams,
secretParams,
request: (sender, requestOptions) =>
sender.createGroup(groupProto, requestOptions),
});
} catch (error) {
if (!(error instanceof HTTPError)) {
throw error;
}
if (error.code !== 400 || refreshedCredentials) {
throw error;
}
const logIds = conversationIds.map(conversationId => {
const contact = window.ConversationController.get(conversationId);
if (!contact) {
return;
}
contact.set({
profileKeyCredential: null,
profileKeyCredentialExpiration: null,
});
return contact.idForLogging();
});
log.warn(
`createGroupV2/${logId}: Profile key credentials were not ` +
`up-to-date. Updating profiles for ${logIds} and retrying`
);
return createGroupV2({
...options,
refreshedCredentials: true,
});
}
let avatarAttribute: ConversationAttributesType['avatar'];
if (uploadedAvatar) {
try {
avatarAttribute = {
url: uploadedAvatar.key,
path: await window.Signal.Migrations.writeNewAttachmentData(
uploadedAvatar.data
),
hash: uploadedAvatar.hash,
};
} catch (err) {
log.warn(
`createGroupV2/${logId}: avatar failed to save to disk. Continuing on`
);
}
}
const now = Date.now();
const conversation = await window.ConversationController.getOrCreateAndWait(
groupId,
'group',
{
...protoAndConversationAttributes,
active_at: now,
addedBy: ourAci,
avatar: avatarAttribute,
avatars,
groupVersion: 2,
masterKey,
profileSharing: true,
timestamp: now,
needsStorageServiceSync: true,
}
);
await conversation.queueJob('storageServiceUploadJob', async () => {
await storageServiceUploadJob();
});
const timestamp = Date.now();
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
strictAssert(groupV2Info, 'missing groupV2Info');
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.GroupUpdate,
conversationId: conversation.id,
recipients: groupV2Info.members.slice(),
revision: groupV2Info.revision,
});
const createdTheGroupMessage: MessageAttributesType = {
...generateBasicMessage(),
type: 'group-v2-change',
sourceServiceId: ourAci,
conversationId: conversation.id,
readStatus: ReadStatus.Read,
received_at: incrementMessageCounter(),
received_at_ms: timestamp,
timestamp,
seenStatus: SeenStatus.Seen,
sent_at: timestamp,
groupV2Change: {
from: ourAci,
details: [{ type: 'create' }],
},
};
await dataInterface.saveMessages([createdTheGroupMessage], {
forceSave: true,
ourAci,
});
let model = new window.Whisper.Message(createdTheGroupMessage);
model = window.MessageCache.__DEPRECATED$register(
model.id,
model,
'createGroupV2'
);
conversation.trigger('newmessage', model);
if (expireTimer) {
await conversation.updateExpirationTimer(expireTimer, {
reason: 'createGroupV2',
});
}
return conversation;
}
// Migrating a group
export async function hasV1GroupBeenMigrated(
conversation: ConversationModel
): Promise<boolean> {
const logId = conversation.idForLogging();
const isGroupV1 = getIsGroupV1(conversation.attributes);
if (!isGroupV1) {
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 = Bytes.fromBinary(groupId);
const masterKeyBuffer = deriveMasterKeyFromGroupV1(idBuffer);
const fields = deriveGroupFields(masterKeyBuffer);
try {
await makeRequestWithTemporalRetry({
logId: `getGroup/${logId}`,
publicParams: Bytes.toBase64(fields.publicParams),
secretParams: Bytes.toBase64(fields.secretParams),
request: (sender, options) => sender.getGroup(options),
});
return true;
} catch (error) {
const { code } = error;
return code !== GROUP_NONEXISTENT_CODE;
}
}
export function maybeDeriveGroupV2Id(conversation: ConversationModel): boolean {
const isGroupV1 = getIsGroupV1(conversation.attributes);
const groupV1Id = conversation.get('groupId');
const derived = conversation.get('derivedGroupV2Id');
if (!isGroupV1 || !groupV1Id || derived) {
return false;
}
const v1IdBuffer = Bytes.fromBinary(groupV1Id);
const masterKeyBuffer = deriveMasterKeyFromGroupV1(v1IdBuffer);
const fields = deriveGroupFields(masterKeyBuffer);
const derivedGroupV2Id = Bytes.toBase64(fields.id);
conversation.set({
derivedGroupV2Id,
});
return true;
}
type WrappedGroupChangeType = Readonly<{
base64: string;
isTrusted: boolean;
}>;
type MigratePropsType = Readonly<{
conversation: ConversationModel;
newRevision?: number;
receivedAt?: number;
sentAt?: number;
groupChange?: WrappedGroupChangeType;
}>;
export async function isGroupEligibleToMigrate(
conversation: ConversationModel
): Promise<boolean> {
if (!getIsGroupV1(conversation.attributes)) {
return false;
}
const ourAci = window.storage.user.getCheckedAci();
const areWeMember =
!conversation.get('left') && conversation.hasMember(ourAci);
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.getServiceId()) {
return false;
}
}
return true;
}
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 = Proto.Member.Role;
const ourConversationId =
window.ConversationController.getOurConversationId();
if (!ourConversationId) {
throw new Error(
`getGroupMigrationMembers/${logId}: Couldn't fetch our own conversationId!`
);
}
const ourAci = window.storage.user.getCheckedAci();
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.`
);
}
if (
!isMe(contact.attributes) &&
window.Flags.GV2_MIGRATION_DISABLE_ADD
) {
log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - skipping ${e164} due to GV2_MIGRATION_DISABLE_ADD flag`
);
return null;
}
const contactAci = contact.getAci();
if (!contactAci) {
log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - missing aci for ${e164}, skipping.`
);
return null;
}
if (!contact.get('profileKey')) {
log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - missing profileKey for member ${e164}, skipping.`
);
return null;
}
// Refresh our local data to be sure
if (!contact.get('profileKeyCredential')) {
await contact.getProfiles();
}
if (!contact.get('profileKeyCredential')) {
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 {
aci: contactAci,
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;
}
if (
!isMe(contact.attributes) &&
window.Flags.GV2_MIGRATION_DISABLE_INVITE
) {
log.warn(
`getGroupMigrationMembers/${logId}: pendingMembersV2 - skipping ${e164} due to GV2_MIGRATION_DISABLE_INVITE flag`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
const contactUuid = contact.getServiceId();
if (!contactUuid) {
log.warn(
`getGroupMigrationMembers/${logId}: pendingMembersV2 - missing uuid for ${e164}, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
if (conversationId === ourConversationId) {
areWeInvited = true;
}
return {
serviceId: contactUuid,
timestamp: now,
addedByUserId: ourAci,
role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
};
})
);
if (!areWeMember) {
throw new Error(`getGroupMigrationMembers/${logId}: We are not a member!`);
}
if (areWeInvited) {
throw new Error(`getGroupMigrationMembers/${logId}: We are invited!`);
}
return {
droppedGV2MemberIds,
membersV2,
pendingMembersV2,
previousGroupV1Members,
};
}
// 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('initiateMigrationToGroupV2', async () => {
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const isEligible = await isGroupEligibleToMigrate(conversation);
const previousGroupV1Id = conversation.get('groupId');
if (!isEligible || !previousGroupV1Id) {
throw new Error(
`initiateMigrationToGroupV2: conversation is not eligible to migrate! ${conversation.idForLogging()}`
);
}
const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id);
const masterKeyBuffer = deriveMasterKeyFromGroupV1(groupV1IdBuffer);
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = Bytes.toBase64(fields.id);
const logId = `groupv2(${groupId})`;
log.info(
`initiateMigrationToGroupV2/${logId}: Migrating from ${conversation.idForLogging()}`
);
const masterKey = Bytes.toBase64(masterKeyBuffer);
const secretParams = Bytes.toBase64(fields.secretParams);
const publicParams = Bytes.toBase64(fields.publicParams);
const ourConversationId =
window.ConversationController.getOurConversationId();
if (!ourConversationId) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!`
);
}
const ourConversation =
window.ConversationController.get(ourConversationId);
if (!ourConversation) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: cannot get our own conversation. Cannot migrate`
);
}
const {
membersV2,
pendingMembersV2,
droppedGV2MemberIds,
previousGroupV1Members,
} = await getGroupMigrationMembers(conversation);
if (
membersV2.length + pendingMembersV2.length >
getGroupSizeHardLimit()
) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
);
}
// Note: A few group elements don't need to change here:
// - name
// - expireTimer
let avatarAttribute: ConversationAttributesType['avatar'];
const avatarPath = conversation.attributes.avatar?.path;
if (avatarPath) {
const { hash, key } = await uploadAvatar({
logId,
publicParams,
secretParams,
path: avatarPath,
});
avatarAttribute = {
url: key,
path: avatarPath,
hash,
};
}
const newAttributes = {
...conversation.attributes,
avatar: avatarAttribute,
// Core GroupV2 info
revision: 0,
groupId,
groupVersion: 2,
masterKey,
publicParams,
secretParams,
// GroupV2 state
accessControl: {
attributes: ACCESS_ENUM.MEMBER,
members: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
},
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 = buildGroupProto({
...newAttributes,
avatarUrl: avatarAttribute?.url,
});
try {
await makeRequestWithTemporalRetry({
logId: `createGroup/${logId}`,
publicParams,
secretParams,
request: (sender, options) => sender.createGroup(groupProto, options),
});
} catch (error) {
log.error(
`initiateMigrationToGroupV2/${logId}: Error creating group:`,
Errors.toLogFormat(error)
);
throw error;
}
const groupChangeMessages: Array<GroupChangeMessageType> = [];
groupChangeMessages.push({
...generateBasicMessage(),
type: 'group-v1-migration',
invitedGV2Members: pendingMembersV2.map(
({ serviceId: uuid, ...rest }) => {
return { ...rest, uuid };
}
),
droppedGV2MemberIds,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
});
await updateGroup({
conversation,
updates: {
newAttributes,
groupChangeMessages,
newProfileKeys: new Map(),
},
});
if (window.storage.blocked.isGroupBlocked(previousGroupV1Id)) {
await window.storage.blocked.addBlockedGroup(groupId);
}
// Save these most recent updates to conversation
updateConversation(conversation.attributes);
});
} catch (error) {
const logId = conversation.idForLogging();
if (!getIsGroupV1(conversation.attributes)) {
throw error;
}
const alreadyMigrated = await hasV1GroupBeenMigrated(conversation);
if (!alreadyMigrated) {
log.error(
`initiateMigrationToGroupV2/${logId}: Group has not already been migrated, re-throwing error`
);
throw error;
}
await respondToGroupV2Migration({
conversation,
});
return;
}
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
strictAssert(groupV2Info, 'missing groupV2Info');
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.GroupUpdate,
conversationId: conversation.id,
recipients: groupV2Info.members.slice(),
revision: groupV2Info.revision,
});
}
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('waitThenRespondToGroupV2Migration', async () => {
try {
// And finally try to migrate the group
await respondToGroupV2Migration(options);
} catch (error) {
log.error(
`waitThenRespondToGroupV2Migration/${conversation.idForLogging()}: respondToGroupV2Migration failure:`,
Errors.toLogFormat(error)
);
}
});
}
export function buildMigrationBubble(
previousGroupV1MembersIds: ReadonlyArray<string>,
newAttributes: ConversationAttributesType
): GroupChangeMessageType {
const ourAci = window.storage.user.getCheckedAci();
const ourPni = window.storage.user.getPni();
const ourConversationId =
window.ConversationController.getOurConversationId();
// Assemble items to commemorate this event for the timeline..
const combinedConversationIds: Array<string> = [
...(newAttributes.membersV2 || []).map(item => item.aci),
...(newAttributes.pendingMembersV2 || []).map(item => item.serviceId),
].map(serviceId => {
const conversation = window.ConversationController.lookupOrCreate({
serviceId,
reason: 'buildMigrationBubble',
});
strictAssert(conversation, `Conversation not found for ${serviceId}`);
return conversation.id;
});
const droppedMemberIds: Array<string> = difference(
previousGroupV1MembersIds,
combinedConversationIds
).filter(id => id && id !== ourConversationId);
const invitedMembers = (newAttributes.pendingMembersV2 || []).filter(
item => item.serviceId !== ourAci && !(ourPni && item.serviceId === ourPni)
);
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
item => item.serviceId === ourAci || (ourPni && item.serviceId === ourPni)
);
return {
...generateBasicMessage(),
type: 'group-v1-migration',
groupMigration: {
areWeInvited,
invitedMembers: invitedMembers.map(({ serviceId: uuid, ...rest }) => {
return { ...rest, uuid };
}),
droppedMemberIds,
},
};
}
export function getBasicMigrationBubble(): GroupChangeMessageType {
return {
...generateBasicMessage(),
type: 'group-v1-migration',
groupMigration: {
areWeInvited: false,
invitedMembers: [],
droppedMemberIds: [],
},
};
}
export async function joinGroupV2ViaLinkAndMigrate({
approvalRequired,
conversation,
inviteLinkPassword,
revision,
}: {
approvalRequired: boolean;
conversation: ConversationModel;
inviteLinkPassword: string;
revision: number;
}): Promise<void> {
const isGroupV1 = getIsGroupV1(conversation.attributes);
const previousGroupV1Id = conversation.get('groupId');
if (!isGroupV1 || !previousGroupV1Id) {
throw new Error(
`joinGroupV2ViaLinkAndMigrate: Conversation is not GroupV1! ${conversation.idForLogging()}`
);
}
// Derive GroupV2 fields
const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id);
const masterKeyBuffer = deriveMasterKeyFromGroupV1(groupV1IdBuffer);
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = Bytes.toBase64(fields.id);
const logId = idForLogging(groupId);
log.info(
`joinGroupV2ViaLinkAndMigrate/${logId}: Migrating from ${conversation.idForLogging()}`
);
const masterKey = Bytes.toBase64(masterKeyBuffer);
const secretParams = Bytes.toBase64(fields.secretParams);
const publicParams = Bytes.toBase64(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,
addedBy: undefined,
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: Array<GroupChangeMessageType> = [
{
...generateBasicMessage(),
type: 'group-v1-migration',
groupMigration: {
areWeInvited: false,
invitedMembers: [],
droppedMemberIds: [],
},
},
];
await updateGroup({
conversation,
updates: {
newAttributes,
groupChangeMessages,
newProfileKeys: new Map(),
},
});
// Now things are set up, so we can go through normal channels
await conversation.joinGroupV2ViaLink({
inviteLinkPassword,
approvalRequired,
});
}
// 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,
groupChange,
newRevision,
receivedAt,
sentAt,
}: MigratePropsType): Promise<void> {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
const isGroupV1 = getIsGroupV1(conversation.attributes);
const previousGroupV1Id = conversation.get('groupId');
if (!isGroupV1 || !previousGroupV1Id) {
throw new Error(
`respondToGroupV2Migration: Conversation is not GroupV1! ${conversation.idForLogging()}`
);
}
const ourAci = window.storage.user.getCheckedAci();
const wereWePreviouslyAMember = conversation.hasMember(ourAci);
// Derive GroupV2 fields
const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id);
const masterKeyBuffer = deriveMasterKeyFromGroupV1(groupV1IdBuffer);
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = Bytes.toBase64(fields.id);
const logId = idForLogging(groupId);
log.info(
`respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}`
);
const masterKey = Bytes.toBase64(masterKeyBuffer);
const secretParams = Bytes.toBase64(fields.secretParams);
const publicParams = Bytes.toBase64(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: Proto.IGroup | null | undefined;
try {
const response: GroupLogResponseType = await makeRequestWithTemporalRetry({
logId: `getGroupLog/${logId}`,
publicParams,
secretParams,
request: (sender, options) =>
sender.getGroupLog(
{
startVersion: 0,
includeFirstState: true,
includeLastState: false,
maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH,
},
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) {
log.info(
`respondToGroupV2Migration/${logId}: Failed to access log endpoint; fetching full group state`
);
try {
firstGroupState = await makeRequestWithTemporalRetry({
logId: `getGroup/${logId}`,
publicParams,
secretParams,
request: (sender, options) => sender.getGroup(options),
});
} catch (secondError) {
if (secondError.code === GROUP_ACCESS_DENIED_CODE) {
log.info(
`respondToGroupV2Migration/${logId}: Failed to access state endpoint; user is no longer part of group`
);
if (window.storage.blocked.isGroupBlocked(previousGroupV1Id)) {
await window.storage.blocked.addBlockedGroup(groupId);
}
if (wereWePreviouslyAMember) {
log.info(
`respondToGroupV2Migration/${logId}: Upgrading group with migration/removed events`
);
const ourNumber = window.textsecure.storage.user.getNumber();
await updateGroup({
conversation,
receivedAt,
sentAt,
updates: {
newAttributes: {
// Because we're using attributes here, we upgrade this to a v2 group
...attributes,
addedBy: undefined,
left: true,
members: (conversation.get('members') || []).filter(
item => item !== ourAci && item !== ourNumber
),
},
groupChangeMessages: [
{
...getBasicMigrationBubble(),
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
},
{
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'member-remove' as const,
aci: ourAci,
},
],
},
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
},
],
newProfileKeys: new Map(),
},
});
return;
}
log.info(
`respondToGroupV2Migration/${logId}: Upgrading group with migration event; no removed event`
);
await updateGroup({
conversation,
receivedAt,
sentAt,
updates: {
newAttributes: attributes,
groupChangeMessages: [
{
...getBasicMigrationBubble(),
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
},
],
newProfileKeys: new Map(),
},
});
return;
}
throw secondError;
}
} 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, newProfileKeys } = await applyGroupState({
group: attributes,
groupState,
});
// Generate notifications into the timeline
const groupChangeMessages: Array<GroupChangeMessageType> = [];
groupChangeMessages.push({
...buildMigrationBubble(previousGroupV1MembersIds, newAttributes),
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
});
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
item => item.serviceId === ourAci
);
const areWeMember = (newAttributes.membersV2 || []).some(
item => item.aci === ourAci
);
if (!areWeInvited && !areWeMember) {
// Add a message to the timeline saying the user was removed. This shouldn't happen.
groupChangeMessages.push({
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'member-remove' as const,
aci: ourAci,
},
],
},
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
});
}
// 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,
newProfileKeys: profileKeysToMap(newProfileKeys),
},
});
if (window.storage.blocked.isGroupBlocked(previousGroupV1Id)) {
await window.storage.blocked.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,
groupChange,
newRevision,
receivedAt,
sentAt,
});
}
// Fetching and applying group changes
type MaybeUpdatePropsType = Readonly<{
conversation: ConversationModel;
newRevision?: number;
receivedAt?: number;
sentAt?: number;
dropInitialJoinMessage?: boolean;
force?: boolean;
groupChange?: WrappedGroupChangeType;
}>;
const FIVE_MINUTES = 5 * MINUTE;
export async function waitThenMaybeUpdateGroup(
options: MaybeUpdatePropsType,
{ viaFirstStorageSync = false } = {}
): Promise<void> {
const { conversation } = options;
if (conversation.isBlocked()) {
log.info(
`waitThenMaybeUpdateGroup: Group ${conversation.idForLogging()} is blocked, returning early`
);
return;
}
// First wait to process all incoming messages on the websocket
await window.waitForEmptyEventQueue();
// Then make sure we haven't fetched this group too recently
const { lastSuccessfulGroupFetch = 0 } = conversation;
if (
!options.force &&
isMoreRecentThan(lastSuccessfulGroupFetch, FIVE_MINUTES)
) {
const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date.now();
log.info(
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: group update ` +
`was fetched recently, skipping for ${waitTime}ms`
);
return;
}
// Then wait to process all outstanding messages for this conversation
await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
try {
// And finally try to update the group
await maybeUpdateGroup(options, { viaFirstStorageSync });
conversation.lastSuccessfulGroupFetch = Date.now();
} catch (error) {
log.error(
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: maybeUpdateGroup failure:`,
Errors.toLogFormat(error)
);
}
});
}
export async function maybeUpdateGroup(
{
conversation,
dropInitialJoinMessage,
groupChange,
newRevision,
receivedAt,
sentAt,
}: MaybeUpdatePropsType,
{ viaFirstStorageSync = false } = {}
): Promise<void> {
const logId = conversation.idForLogging();
try {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
const updates = await getGroupUpdates({
group: conversation.attributes,
serverPublicParamsBase64: window.getServerPublicParams(),
newRevision,
groupChange,
dropInitialJoinMessage,
});
await updateGroup(
{ conversation, receivedAt, sentAt, updates },
{ viaFirstStorageSync }
);
} catch (error) {
log.error(
`maybeUpdateGroup/${logId}: Failed to update group:`,
Errors.toLogFormat(error)
);
throw error;
}
}
async function updateGroup(
{
conversation,
receivedAt,
sentAt,
updates,
}: {
conversation: ConversationModel;
receivedAt?: number;
sentAt?: number;
updates: UpdatesResultType;
},
{ viaFirstStorageSync = false } = {}
): Promise<void> {
const logId = conversation.idForLogging();
const { newAttributes, groupChangeMessages, newProfileKeys } = updates;
const ourAci = window.textsecure.storage.user.getCheckedAci();
const ourPni = window.textsecure.storage.user.getPni();
const startingRevision = conversation.get('revision');
const endingRevision = newAttributes.revision;
const wasMemberOrPending =
conversation.hasMember(ourAci) ||
conversation.isMemberPending(ourAci) ||
(ourPni && conversation.isMemberPending(ourPni));
const isMemberOrPending =
!newAttributes.left ||
newAttributes.pendingMembersV2?.some(
item => item.serviceId === ourAci || item.serviceId === ourPni
);
const isMemberOrPendingOrAwaitingApproval =
isMemberOrPending ||
newAttributes.pendingAdminApprovalV2?.some(item => item.aci === ourAci);
const isInitialDataFetch =
!isNumber(startingRevision) && isNumber(endingRevision);
// Ensure that all generated messages are ordered properly.
// Before the provided timestamp so update messages appear before the
// initiating message, or after now().
const finalReceivedAt = receivedAt || incrementMessageCounter();
const initialSentAt = 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;
// By updating activeAt 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. After first fetch, we rely on normal
// message activity (including group change messages) to set the timestamp properly.
let activeAt = conversation.get('active_at') || null;
if (
!viaFirstStorageSync &&
isMemberOrPendingOrAwaitingApproval &&
isInitialDataFetch &&
newAttributes.name
) {
activeAt = initialSentAt;
}
// Save all synthetic messages describing group changes
let syntheticSentAt = initialSentAt - (groupChangeMessages.length + 1);
const timestamp = Date.now();
const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
// We do this to preserve the order of the timeline. We only update sentAt to ensure
// that we don't stomp on messages received around the same time as the message
// which initiated this group fetch and in-conversation messages.
syntheticSentAt += 1;
return {
...changeMessage,
conversationId: conversation.id,
received_at: finalReceivedAt,
received_at_ms: syntheticSentAt,
sent_at: syntheticSentAt,
timestamp,
};
});
const contactsWithoutProfileKey = new Array<ConversationModel>();
// Capture profile key for each member in the group, if we don't have it yet
for (const [aci, profileKey] of newProfileKeys) {
const contact = window.ConversationController.getOrCreate(aci, 'private');
if (
!isMe(contact.attributes) &&
profileKey &&
profileKey.length > 0 &&
contact.get('profileKey') !== profileKey
) {
contactsWithoutProfileKey.push(contact);
drop(contact.setProfileKey(profileKey));
}
}
let profileFetches: Promise<Array<void>> | undefined;
if (contactsWithoutProfileKey.length !== 0) {
log.info(
`updateGroup/${logId}: fetching ` +
`${contactsWithoutProfileKey.length} missing profiles`
);
profileFetches = Promise.all(
contactsWithoutProfileKey.map(contact => contact.getProfiles())
);
}
// If we've been added by a blocked contact, then schedule a task to leave group
const justAdded = !wasMemberOrPending && isMemberOrPending;
const addedBy =
newAttributes.pendingMembersV2?.find(
item => item.serviceId === ourAci || item.serviceId === ourPni
)?.addedByUserId || newAttributes.addedBy;
if (justAdded && addedBy) {
const adder = window.ConversationController.get(addedBy);
if (adder && adder.isBlocked()) {
log.warn(
`updateGroup/${logId}: Added to group by blocked user ${adder.idForLogging()}. Scheduling group leave.`
);
// Wait for empty queue to make it more likely the group update succeeds
const waitThenLeave = async () => {
log.warn(`waitThenLeave/${logId}: Waiting for empty event queue.`);
await window.waitForEmptyEventQueue();
log.warn(
`waitThenLeave/${logId}: Empty event queue, starting group leave.`
);
await conversation.leaveGroupV2();
log.warn(`waitThenLeave/${logId}: Leave complete.`);
};
// Cannot await here, would infinitely block queue
drop(waitThenLeave());
// Return early to discard group changes resulting from the blocked user's action.
return;
}
}
// We update group membership last to ensure that all notifications are in place before
// the group updates happen on the model.
if (changeMessagesToSave.length > 0) {
try {
if (contactsWithoutProfileKey && contactsWithoutProfileKey.length > 0) {
await Promise.race([profileFetches, sleep(30 * SECOND)]);
log.info(
`updateGroup/${logId}: timed out or finished fetching ${contactsWithoutProfileKey.length} profiles`
);
}
} catch (error) {
log.error(
`updateGroup/${logId}: failed to fetch missing profiles`,
Errors.toLogFormat(error)
);
}
await appendChangeMessages(conversation, changeMessagesToSave);
}
conversation.set({
...newAttributes,
active_at: activeAt,
});
if (idChanged) {
conversation.trigger('idUpdated', conversation, 'groupId', previousId);
}
// Save these most recent updates to conversation
await updateConversation(conversation.attributes);
}
// Exported for testing
export function _mergeGroupChangeMessages(
first: MessageAttributesType | undefined,
second: MessageAttributesType
): MessageAttributesType | undefined {
if (!first) {
return undefined;
}
if (first.type !== 'group-v2-change' || second.type !== first.type) {
return undefined;
}
const { groupV2Change: firstChange } = first;
const { groupV2Change: secondChange } = second;
if (!firstChange || !secondChange) {
return undefined;
}
if (firstChange.details.length !== 1 && secondChange.details.length !== 1) {
return undefined;
}
const [firstDetail] = firstChange.details;
const [secondDetail] = secondChange.details;
let isApprovalPending: boolean;
if (secondDetail.type === 'admin-approval-add-one') {
isApprovalPending = true;
} else if (secondDetail.type === 'admin-approval-remove-one') {
isApprovalPending = false;
} else {
return undefined;
}
const { aci } = secondDetail;
strictAssert(aci, 'admin approval message should have aci');
let updatedDetail;
// Member was previously added and is now removed
if (
!isApprovalPending &&
firstDetail.type === 'admin-approval-add-one' &&
firstDetail.aci === aci
) {
updatedDetail = {
type: 'admin-approval-bounce' as const,
aci,
times: 1,
isApprovalPending,
};
// There is an existing bounce event - merge this one into it.
} else if (
firstDetail.type === 'admin-approval-bounce' &&
firstDetail.aci === aci &&
firstDetail.isApprovalPending === !isApprovalPending
) {
updatedDetail = {
type: 'admin-approval-bounce' as const,
aci,
times: firstDetail.times + (isApprovalPending ? 0 : 1),
isApprovalPending,
};
} else {
return undefined;
}
return {
...first,
groupV2Change: {
...first.groupV2Change,
details: [updatedDetail],
},
};
}
// Exported for testing
export function _isGroupChangeMessageBounceable(
message: MessageAttributesType
): boolean {
if (message.type !== 'group-v2-change') {
return false;
}
const { groupV2Change } = message;
if (!groupV2Change) {
return false;
}
if (groupV2Change.details.length !== 1) {
return false;
}
const [first] = groupV2Change.details;
if (
first.type === 'admin-approval-add-one' ||
first.type === 'admin-approval-bounce'
) {
return true;
}
return false;
}
async function appendChangeMessages(
conversation: ConversationModel,
messages: ReadonlyArray<MessageAttributesType>
): Promise<void> {
const logId = conversation.idForLogging();
log.info(
`appendChangeMessages/${logId}: processing ${messages.length} messages`
);
const ourAci = window.textsecure.storage.user.getCheckedAci();
let lastMessage = await dataInterface.getLastConversationMessage({
conversationId: conversation.id,
});
if (lastMessage && !_isGroupChangeMessageBounceable(lastMessage)) {
lastMessage = undefined;
}
const mergedMessages = [];
let previousMessage = lastMessage;
for (const message of messages) {
const merged = _mergeGroupChangeMessages(previousMessage, message);
if (!merged) {
if (previousMessage && previousMessage !== lastMessage) {
mergedMessages.push(previousMessage);
}
previousMessage = message;
continue;
}
previousMessage = merged;
log.info(
`appendChangeMessages/${logId}: merged ${message.id} into ${merged.id}`
);
}
if (previousMessage && previousMessage !== lastMessage) {
mergedMessages.push(previousMessage);
}
// Update existing message
if (lastMessage && mergedMessages[0]?.id === lastMessage?.id) {
const [first, ...rest] = mergedMessages;
strictAssert(first !== undefined, 'First message must be there');
log.info(`appendChangeMessages/${logId}: updating ${first.id}`);
await dataInterface.saveMessage(first, {
ourAci,
// We don't use forceSave here because this is an update of existing
// message.
});
log.info(
`appendChangeMessages/${logId}: saving ${rest.length} new messages`
);
await dataInterface.saveMessages(rest, {
ourAci,
forceSave: true,
});
} else {
log.info(
`appendChangeMessages/${logId}: saving ${mergedMessages.length} new messages`
);
await dataInterface.saveMessages(mergedMessages, {
ourAci,
forceSave: true,
});
}
let newMessages = 0;
for (const changeMessage of mergedMessages) {
const existing = window.MessageCache.__DEPRECATED$getById(changeMessage.id);
// Update existing message
if (existing) {
strictAssert(
changeMessage.id === lastMessage?.id,
'Should only update group change that was already in the database'
);
existing.set(changeMessage);
continue;
}
let model = new window.Whisper.Message(changeMessage);
model = window.MessageCache.__DEPRECATED$register(
model.id,
model,
'appendChangeMessages'
);
conversation.trigger('newmessage', model);
newMessages += 1;
}
// We updated the message, but didn't add new ones - refresh left pane
if (!newMessages && mergedMessages.length > 0) {
await conversation.updateLastMessage();
void conversation.updateUnread();
}
}
type GetGroupUpdatesType = Readonly<{
dropInitialJoinMessage?: boolean;
group: ConversationAttributesType;
serverPublicParamsBase64: string;
newRevision?: number;
groupChange?: WrappedGroupChangeType;
}>;
async function getGroupUpdates({
dropInitialJoinMessage,
group,
serverPublicParamsBase64,
newRevision,
groupChange: wrappedGroupChange,
}: GetGroupUpdatesType): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
log.info(`getGroupUpdates/${logId}: Starting...`);
const currentRevision = group.revision;
const isFirstFetch = !isNumber(group.revision);
const ourAci = window.storage.user.getCheckedAci();
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
item => item.aci === ourAci
);
const isOneVersionUp =
isNumber(currentRevision) &&
isNumber(newRevision) &&
newRevision === currentRevision + 1;
if (
window.Flags.GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
wrappedGroupChange &&
isNumber(newRevision) &&
(isInitialCreationMessage || weAreAwaitingApproval || isOneVersionUp)
) {
log.info(`getGroupUpdates/${logId}: Processing just one change`);
const groupChangeBuffer = Bytes.fromBase64(wrappedGroupChange.base64);
const groupChange = Proto.GroupChange.decode(groupChangeBuffer);
const isChangeSupported =
!isNumber(groupChange.changeEpoch) ||
groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;
if (isChangeSupported) {
if (!wrappedGroupChange.isTrusted) {
strictAssert(
groupChange.serverSignature,
'Server signature must be present in untrusted group change'
);
strictAssert(
groupChange.actions,
'Actions must be present in untrusted group change'
);
try {
verifyNotarySignature(
serverPublicParamsBase64,
groupChange.actions,
groupChange.serverSignature
);
} catch (error) {
log.warn(
`getGroupUpdates/${logId}: verifyNotarySignature failed, ` +
'dropping the message',
Errors.toLogFormat(error)
);
return {
newAttributes: group,
groupChangeMessages: [],
newProfileKeys: new Map(),
};
}
}
return updateGroupViaSingleChange({
group,
newRevision,
groupChange,
});
}
log.info(
`getGroupUpdates/${logId}: Failing over; group change unsupported`
);
}
if (
(!isFirstFetch || isNumber(newRevision)) &&
window.Flags.GV2_ENABLE_CHANGE_PROCESSING
) {
try {
return await updateGroupViaLogs({
group,
newRevision,
});
} catch (error) {
const nextStep = isFirstFetch
? `fetching logs since ${newRevision}`
: 'fetching full state';
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
// We will fail over to the updateGroupViaState call below
log.info(
`getGroupUpdates/${logId}: Temporal credential failure, now ${nextStep}`
);
} else if (error.code === GROUP_ACCESS_DENIED_CODE) {
// We will fail over to the updateGroupViaState call below
log.info(
`getGroupUpdates/${logId}: Log access denied, now ${nextStep}`
);
} else {
throw error;
}
}
}
if (window.Flags.GV2_ENABLE_STATE_PROCESSING) {
try {
return await updateGroupViaState({
dropInitialJoinMessage,
group,
});
} catch (error) {
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
log.info(
`getGroupUpdates/${logId}: Temporal credential failure. Failing; we don't know if we have access or not.`
);
throw error;
} else if (error.code === GROUP_ACCESS_DENIED_CODE) {
// We will fail over to the updateGroupViaPreJoinInfo call below
log.info(
`getGroupUpdates/${logId}: Failed to get group state. Attempting to fetch pre-join information.`
);
} else {
throw error;
}
}
}
if (window.Flags.GV2_ENABLE_PRE_JOIN_FETCH) {
try {
return await updateGroupViaPreJoinInfo({
group,
});
} catch (error) {
if (error.code === GROUP_ACCESS_DENIED_CODE) {
return generateLeftGroupChanges(group);
}
if (error.code === GROUP_NONEXISTENT_CODE) {
return generateLeftGroupChanges(group);
}
// If we get another temporal failure, we'll fail and try again later.
throw error;
}
}
log.warn(
`getGroupUpdates/${logId}: No processing was legal! Returning empty changeset.`
);
return {
newAttributes: group,
groupChangeMessages: [],
newProfileKeys: new Map(),
};
}
async function updateGroupViaPreJoinInfo({
group,
}: {
group: ConversationAttributesType;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
const ourAci = window.textsecure.storage.user.getCheckedAci();
const { publicParams, secretParams } = group;
if (!secretParams) {
throw new Error(
'updateGroupViaPreJoinInfo: group was missing secretParams!'
);
}
if (!publicParams) {
throw new Error(
'updateGroupViaPreJoinInfo: group was missing publicParams!'
);
}
// No password, but if we're already pending approval, we can access this without it.
const inviteLinkPassword = undefined;
const preJoinInfo = await makeRequestWithTemporalRetry({
logId: `getPreJoinInfo/${logId}`,
publicParams,
secretParams,
request: (sender, options) =>
sender.getGroupFromLink(inviteLinkPassword, options),
});
const approvalRequired =
preJoinInfo.addFromInviteLink ===
Proto.AccessControl.AccessRequired.ADMINISTRATOR;
// If the group doesn't require approval to join via link, then we should never have
// gotten here.
if (!approvalRequired) {
return generateLeftGroupChanges(group);
}
const newAttributes: ConversationAttributesType = {
...group,
description: decryptGroupDescription(
dropNull(preJoinInfo.descriptionBytes),
secretParams
),
name: decryptGroupTitle(dropNull(preJoinInfo.title), secretParams),
left: true,
members: group.members || [],
pendingMembersV2: group.pendingMembersV2 || [],
pendingAdminApprovalV2: [
{
aci: ourAci,
timestamp: Date.now(),
},
],
revision: dropNull(preJoinInfo.version),
temporaryMemberCount: preJoinInfo.memberCount || 1,
};
await applyNewAvatar(dropNull(preJoinInfo.avatar), newAttributes, logId);
return {
newAttributes,
groupChangeMessages: extractDiffs({
old: group,
current: newAttributes,
dropInitialJoinMessage: false,
}),
newProfileKeys: new Map(),
};
}
async function updateGroupViaState({
dropInitialJoinMessage,
group,
}: {
dropInitialJoinMessage?: boolean;
group: ConversationAttributesType;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
const { publicParams, secretParams } = group;
if (!secretParams) {
throw new Error('updateGroupViaState: group was missing secretParams!');
}
if (!publicParams) {
throw new Error('updateGroupViaState: group was missing publicParams!');
}
const groupState = await makeRequestWithTemporalRetry({
logId: `getGroup/${logId}`,
publicParams,
secretParams,
request: (sender, requestOptions) => sender.getGroup(requestOptions),
});
const decryptedGroupState = decryptGroupState(
groupState,
secretParams,
logId
);
const oldVersion = group.revision;
const newVersion = decryptedGroupState.version;
log.info(
`getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.`
);
const { newAttributes, newProfileKeys } = await applyGroupState({
group,
groupState: decryptedGroupState,
});
return {
newAttributes,
groupChangeMessages: extractDiffs({
old: group,
current: newAttributes,
dropInitialJoinMessage,
}),
newProfileKeys: profileKeysToMap(newProfileKeys),
};
}
async function updateGroupViaSingleChange({
group,
groupChange,
newRevision,
}: {
group: ConversationAttributesType;
groupChange: Proto.IGroupChange;
newRevision: number;
}): Promise<UpdatesResultType> {
const previouslyKnewAboutThisGroup =
isNumber(group.revision) && group.membersV2?.length;
const wasInGroup = !group.left;
const singleChangeResult: UpdatesResultType = await integrateGroupChange({
group,
groupChange,
newRevision,
});
const nowInGroup = !singleChangeResult.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. Note: we fetch the group state
// via the log endpoint to stay at newRevision.
if (!wasInGroup && nowInGroup) {
const logId = idForLogging(group.groupId);
log.info(
`updateGroupViaSingleChange/${logId}: Just joined group; fetching entire state for revision ${newRevision}.`
);
const {
newAttributes,
newProfileKeys,
groupChangeMessages: catchupMessages,
} = await updateGroupViaLogs({
group: singleChangeResult.newAttributes,
newRevision,
});
const groupChangeMessages = [...singleChangeResult.groupChangeMessages];
// If we've just been added to a group we were previously in, we do want to show
// a summary instead of nothing.
if (
groupChangeMessages.length > 0 &&
previouslyKnewAboutThisGroup &&
catchupMessages.length > 0
) {
groupChangeMessages.push({
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'summary',
},
],
},
readStatus: ReadStatus.Read,
// For simplicity, since we don't know who this change is from here, always Seen
seenStatus: SeenStatus.Seen,
});
}
// 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 {
groupChangeMessages,
newProfileKeys: new Map([
...singleChangeResult.newProfileKeys,
...newProfileKeys,
]),
newAttributes,
};
}
return singleChangeResult;
}
async function updateGroupViaLogs({
group,
newRevision,
}: {
group: ConversationAttributesType;
newRevision: number | undefined;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
const { publicParams, secretParams } = group;
if (!publicParams) {
throw new Error('updateGroupViaLogs: group was missing publicParams!');
}
if (!secretParams) {
throw new Error('updateGroupViaLogs: group was missing secretParams!');
}
log.info(
`updateGroupViaLogs/${logId}: Getting group delta from ` +
`${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` +
`groupv2(${group.groupId})...`
);
const currentRevision = group.revision;
let includeFirstState = true;
// The range is inclusive so make sure that we always request the revision
// that we are currently at since we might want the latest full state in
// `integrateGroupChanges`.
let revisionToFetch = isNumber(currentRevision) ? currentRevision : undefined;
let response;
const changes: Array<Proto.IGroupChanges> = [];
do {
// eslint-disable-next-line no-await-in-loop
response = await makeRequestWithTemporalRetry({
logId: `getGroupLog/${logId}`,
publicParams,
secretParams,
// eslint-disable-next-line no-loop-func
request: (sender, requestOptions) =>
sender.getGroupLog(
{
startVersion: revisionToFetch,
includeFirstState,
includeLastState: true,
maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH,
},
requestOptions
),
});
changes.push(response.changes);
if (response.end) {
revisionToFetch = response.end + 1;
}
includeFirstState = false;
} while (
response.end &&
(newRevision === undefined || response.end < newRevision)
);
// Would be nice to cache the unused groupChanges here, to reduce server roundtrips
return integrateGroupChanges({
changes,
group,
newRevision,
});
}
async function generateLeftGroupChanges(
group: ConversationAttributesType
): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
log.info(`generateLeftGroupChanges/${logId}: Starting...`);
const ourAci = window.storage.user.getCheckedAci();
const ourPni = window.storage.user.getCheckedPni();
const { masterKey, groupInviteLinkPassword } = group;
let { revision } = group;
try {
if (masterKey && groupInviteLinkPassword) {
log.info(
`generateLeftGroupChanges/${logId}: Have invite link. Attempting to fetch latest revision with it.`
);
const preJoinInfo = await getPreJoinGroupInfo(
groupInviteLinkPassword,
masterKey
);
revision = dropNull(preJoinInfo.version);
}
} catch (error) {
log.warn(
'generateLeftGroupChanges: Failed to fetch latest revision via group link. Code:',
error.code
);
}
const newAttributes: ConversationAttributesType = {
...group,
addedBy: undefined,
membersV2: (group.membersV2 || []).filter(member => member.aci !== ourAci),
pendingMembersV2: (group.pendingMembersV2 || []).filter(
member => member.serviceId !== ourAci && member.serviceId !== ourPni
),
pendingAdminApprovalV2: (group.pendingAdminApprovalV2 || []).filter(
member => member.aci !== ourAci
),
left: true,
revision,
};
return {
newAttributes,
groupChangeMessages: extractDiffs({
current: newAttributes,
old: group,
}),
newProfileKeys: new Map(),
};
}
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: Bytes.toHex(
Bytes.fromBase64(groupPublicParamsBase64)
),
authCredentialPresentationHex: Bytes.toHex(presentation),
};
}
async function integrateGroupChanges({
group,
newRevision,
changes,
}: {
group: ConversationAttributesType;
newRevision: number | undefined;
changes: ReadonlyArray<Proto.IGroupChanges>;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
let attributes = group;
const finalMessages: Array<Array<GroupChangeMessageType>> = [];
const finalNewProfileKeys = new Map<AciString, string>();
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];
const { groupChange, groupState } = changeState;
if (!groupChange && !groupState) {
log.warn(
'integrateGroupChanges: item had neither groupState nor groupChange. Skipping.'
);
continue;
}
try {
const {
newAttributes,
groupChangeMessages,
newProfileKeys,
// eslint-disable-next-line no-await-in-loop
} = await integrateGroupChange({
group: attributes,
newRevision,
groupChange: dropNull(groupChange),
groupState: dropNull(groupState),
});
attributes = newAttributes;
finalMessages.push(groupChangeMessages);
for (const [aci, profileKey] of newProfileKeys) {
finalNewProfileKeys.set(aci, profileKey);
}
} catch (error) {
log.error(
`integrateGroupChanges/${logId}: Failed to apply change log, continuing to apply remaining change logs.`,
Errors.toLogFormat(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,
newProfileKeys: finalNewProfileKeys,
};
}
return {
newAttributes: attributes,
groupChangeMessages: flatten(finalMessages),
newProfileKeys: finalNewProfileKeys,
};
}
async function integrateGroupChange({
group,
groupChange,
groupState,
newRevision,
}: {
group: ConversationAttributesType;
groupChange?: Proto.IGroupChange;
groupState?: Proto.IGroup;
newRevision: number | undefined;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
if (!group.secretParams) {
throw new Error(
`integrateGroupChange/${logId}: Group was missing secretParams!`
);
}
if (!groupChange && !groupState) {
throw new Error(
`integrateGroupChange/${logId}: Neither groupChange nor groupState received!`
);
}
const isFirstFetch = !isNumber(group.revision);
const ourAci = window.storage.user.getCheckedAci();
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
item => item.aci === ourAci
);
// These need to be populated from the groupChange. But we might not get one!
let isChangeSupported = false;
let isSameVersion = false;
let isMoreThanOneVersionUp = false;
let groupChangeActions: undefined | Proto.GroupChange.IActions;
let decryptedChangeActions: undefined | DecryptedGroupChangeActions;
let sourceServiceId: undefined | ServiceIdString;
if (groupChange) {
groupChangeActions = Proto.GroupChange.Actions.decode(
groupChange.actions || new Uint8Array(0)
);
// Version is higher that what we have in the incoming message
if (
groupChangeActions.version &&
newRevision !== undefined &&
groupChangeActions.version > newRevision
) {
log.info(
`integrateGroupChange/${logId}: Skipping ` +
`${groupChangeActions.version}, newRevision is ${newRevision}`
);
return {
newAttributes: group,
groupChangeMessages: [],
newProfileKeys: new Map(),
};
}
decryptedChangeActions = decryptGroupChange(
groupChangeActions,
group.secretParams,
logId
);
strictAssert(
decryptedChangeActions !== undefined,
'Should have decrypted group actions'
);
({ sourceServiceId } = decryptedChangeActions);
strictAssert(sourceServiceId, 'Should have source service id');
isChangeSupported =
!isNumber(groupChange.changeEpoch) ||
groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;
// Version is lower or the same as what we currently have
if (group.revision !== undefined && groupChangeActions.version) {
if (groupChangeActions.version < group.revision) {
log.info(
`integrateGroupChange/${logId}: Skipping stale version` +
`${groupChangeActions.version}, current ` +
`revision is ${group.revision}`
);
return {
newAttributes: group,
groupChangeMessages: [],
newProfileKeys: new Map(),
};
}
if (groupChangeActions.version === group.revision) {
isSameVersion = true;
} else if (
groupChangeActions.version !== group.revision + 1 ||
(!isNumber(group.revision) && groupChangeActions.version > 0)
) {
isMoreThanOneVersionUp = true;
}
}
}
let attributes = group;
const aggregatedChangeMessages = [];
const finalNewProfileKeys = new Map<AciString, string>();
const canApplyChange =
groupChange &&
isChangeSupported &&
!isSameVersion &&
!isFirstFetch &&
(!isMoreThanOneVersionUp || weAreAwaitingApproval);
// Apply the change first
if (canApplyChange) {
if (!sourceServiceId || !groupChangeActions || !decryptedChangeActions) {
throw new Error(
`integrateGroupChange/${logId}: Missing necessary information that should have come from group actions`
);
}
log.info(
`integrateGroupChange/${logId}: Applying group change actions, ` +
`from version ${group.revision} to ${groupChangeActions.version}`
);
const { newAttributes, newProfileKeys, promotedAciToPniMap } =
await applyGroupChange({
group,
actions: decryptedChangeActions,
sourceServiceId,
});
const groupChangeMessages = extractDiffs({
old: attributes,
current: newAttributes,
sourceServiceId,
promotedAciToPniMap,
});
attributes = newAttributes;
aggregatedChangeMessages.push(groupChangeMessages);
for (const [aci, profileKey] of profileKeysToMap(newProfileKeys)) {
finalNewProfileKeys.set(aci, profileKey);
}
}
// Apply the group state afterwards to verify that we didn't miss anything
if (groupState) {
log.info(
`integrateGroupChange/${logId}: Applying full group state, ` +
`from version ${group.revision} to ${groupState.version}`,
{
isChangePresent: Boolean(groupChange),
isChangeSupported,
isFirstFetch,
isSameVersion,
isMoreThanOneVersionUp,
weAreAwaitingApproval,
}
);
const decryptedGroupState = decryptGroupState(
groupState,
group.secretParams,
logId
);
const {
newAttributes,
newProfileKeys: newProfileKeysList,
otherChanges,
} = await applyGroupState({
group: attributes,
groupState: decryptedGroupState,
sourceServiceId: isFirstFetch ? sourceServiceId : undefined,
});
const groupChangeMessages = extractDiffs({
old: attributes,
current: newAttributes,
sourceServiceId: isFirstFetch ? sourceServiceId : undefined,
});
const newProfileKeys = profileKeysToMap(newProfileKeysList);
if (
canApplyChange &&
(groupChangeMessages.length !== 0 ||
newProfileKeys.size !== 0 ||
otherChanges)
) {
assertDev(
groupChangeMessages.length === 0,
'Fallback group state processing should not kick in'
);
log.warn(
`integrateGroupChange/${logId}: local state was different from ` +
'the remote final state. ' +
`Got ${groupChangeMessages.length} change messages, ` +
`${newProfileKeys.size} updated members, and ` +
`otherChanges=${otherChanges}`
);
}
attributes = newAttributes;
aggregatedChangeMessages.push(groupChangeMessages);
for (const [aci, profileKey] of newProfileKeys) {
finalNewProfileKeys.set(aci, profileKey);
}
} else {
strictAssert(
canApplyChange,
`integrateGroupChange/${logId}: No group state, but we can't apply changes!`
);
}
return {
newAttributes: attributes,
groupChangeMessages: aggregatedChangeMessages.flat(),
newProfileKeys: finalNewProfileKeys,
};
}
function extractDiffs({
current,
dropInitialJoinMessage,
old,
sourceServiceId,
promotedAciToPniMap,
}: {
current: ConversationAttributesType;
dropInitialJoinMessage?: boolean;
old: ConversationAttributesType;
sourceServiceId?: ServiceIdString;
promotedAciToPniMap?: ReadonlyMap<AciString, PniString>;
}): Array<GroupChangeMessageType> {
const logId = idForLogging(old.groupId);
const details: Array<GroupV2ChangeDetailType> = [];
const ourAci = window.storage.user.getCheckedAci();
const ourPni = window.storage.user.getPni();
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
let areWeInGroup = false;
let serviceIdKindInvitedToGroup: ServiceIdKind | undefined;
let areWePendingApproval = false;
let whoInvitedUsUserId = null;
function isUs(serviceId: ServiceIdString): boolean {
return serviceId === ourAci || serviceId === ourPni;
}
function keepOnlyOurAdds(
list: Array<GroupV2ChangeDetailType>
): Array<GroupV2ChangeDetailType> {
return list.filter(
item =>
(item.type === 'member-add-from-invite' && isUs(item.aci)) ||
(item.type === 'member-add-from-link' && isUs(item.aci)) ||
(item.type === 'member-add-from-admin-approval' && isUs(item.aci)) ||
(item.type === 'member-add' && isUs(item.aci))
);
}
// access control
if (
current.accessControl &&
old.accessControl &&
old.accessControl.attributes !== undefined &&
old.accessControl.attributes !== current.accessControl.attributes
) {
details.push({
type: 'access-attributes',
newPrivilege: current.accessControl.attributes,
});
}
if (
current.accessControl &&
old.accessControl &&
old.accessControl.members !== undefined &&
old.accessControl.members !== current.accessControl.members
) {
details.push({
type: 'access-members',
newPrivilege: current.accessControl.members,
});
}
const linkPreviouslyEnabled = isAccessControlEnabled(
old.accessControl?.addFromInviteLink
);
const linkCurrentlyEnabled = isAccessControlEnabled(
current.accessControl?.addFromInviteLink
);
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
if (
Boolean(old.avatar) !== Boolean(current.avatar) ||
old.avatar?.hash !== current.avatar?.hash
) {
details.push({
type: 'avatar',
removed: !current.avatar,
});
}
// name
if (old.name !== current.name) {
details.push({
type: 'title',
newTitle: current.name,
});
}
// 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',
});
}
// description
if (old.description !== current.description) {
details.push({
type: 'description',
removed: !current.description,
description: current.description,
});
}
// No disappearing message timer check here - see below
// membersV2
const oldMemberLookup = new Map<AciString, GroupV2MemberType>(
(old.membersV2 || []).map(member => [member.aci, member])
);
const didWeStartInGroup = Boolean(ourAci && oldMemberLookup.has(ourAci));
const oldPendingMemberLookup = new Map<
ServiceIdString,
GroupV2PendingMemberType
>((old.pendingMembersV2 || []).map(member => [member.serviceId, member]));
const oldPendingAdminApprovalLookup = new Map<
AciString,
GroupV2PendingAdminApprovalType
>((old.pendingAdminApprovalV2 || []).map(member => [member.aci, member]));
const currentPendingMemberSet = new Set<ServiceIdString>(
(current.pendingMembersV2 || []).map(member => member.serviceId)
);
const aciToPniMap = new Map(promotedAciToPniMap?.entries());
if (ourAci && ourPni) {
aciToPniMap.set(ourAci, ourPni);
}
const pniToAciMap = new Map<PniString, AciString>();
for (const [aci, pni] of aciToPniMap) {
pniToAciMap.set(pni, aci);
}
(current.membersV2 || []).forEach(currentMember => {
const { aci } = currentMember;
const uuidIsUs = isUs(aci);
if (uuidIsUs) {
areWeInGroup = true;
}
const oldMember = oldMemberLookup.get(aci);
if (!oldMember) {
let pendingMember = oldPendingMemberLookup.get(aci);
const pni = aciToPniMap.get(aci);
if (!pendingMember && pni) {
pendingMember = oldPendingMemberLookup.get(pni);
// Someone's ACI just joined (wasn't a member before) and their PNI
// disappeared from the invite list. Treat this as a promotion from PNI
// to ACI and pretend that the PNI wasn't pending so that we won't
// generate a pending-add-one notification below.
if (pendingMember && !currentPendingMemberSet.has(pni)) {
oldPendingMemberLookup.delete(pni);
}
}
if (pendingMember) {
details.push({
type: 'member-add-from-invite',
aci,
inviter: pendingMember.addedByUserId,
});
} else if (currentMember.joinedFromLink) {
details.push({
type: 'member-add-from-link',
aci,
});
} else if (currentMember.approvedByAdmin) {
details.push({
type: 'member-add-from-admin-approval',
aci,
});
} else {
details.push({
type: 'member-add',
aci,
});
}
} else if (oldMember.role !== currentMember.role) {
details.push({
type: 'member-privilege',
aci,
newPrivilege: currentMember.role,
});
}
// 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.
oldPendingAdminApprovalLookup.delete(aci);
// 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
oldPendingMemberLookup.delete(aci);
// This deletion makes it easier to capture removals
oldMemberLookup.delete(aci);
});
const removedMemberIds = Array.from(oldMemberLookup.keys());
removedMemberIds.forEach(aci => {
details.push({
type: 'member-remove',
aci,
});
});
// pendingMembersV2
let lastPendingServiceId: ServiceIdString | undefined;
let pendingCount = 0;
(current.pendingMembersV2 || []).forEach(currentPendingMember => {
const { serviceId } = currentPendingMember;
const oldPendingMember = oldPendingMemberLookup.get(serviceId);
if (isUs(serviceId)) {
if (serviceId === ourAci) {
serviceIdKindInvitedToGroup = ServiceIdKind.ACI;
} else if (serviceIdKindInvitedToGroup === undefined) {
serviceIdKindInvitedToGroup = ServiceIdKind.PNI;
}
whoInvitedUsUserId = currentPendingMember.addedByUserId;
}
if (!oldPendingMember) {
lastPendingServiceId = serviceId;
pendingCount += 1;
}
// This deletion makes it easier to capture removals
oldPendingMemberLookup.delete(serviceId);
});
if (pendingCount > 1) {
details.push({
type: 'pending-add-many',
count: pendingCount,
});
} else if (pendingCount === 1) {
if (lastPendingServiceId) {
details.push({
type: 'pending-add-one',
serviceId: lastPendingServiceId,
});
} else {
log.warn(
`extractDiffs/${logId}: pendingCount was 1, no last conversationId available`
);
}
}
// 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 = Array.from(oldPendingMemberLookup.keys());
if (removedPendingMemberIds.length > 1) {
const firstUuid = removedPendingMemberIds[0];
const firstRemovedMember = oldPendingMemberLookup.get(firstUuid);
strictAssert(
firstRemovedMember !== undefined,
'First removed member not found'
);
const inviter = firstRemovedMember.addedByUserId;
const allSameInviter = removedPendingMemberIds.every(
id => oldPendingMemberLookup.get(id)?.addedByUserId === inviter
);
details.push({
type: 'pending-remove-many',
count: removedPendingMemberIds.length,
inviter: allSameInviter ? inviter : undefined,
});
} else if (removedPendingMemberIds.length === 1) {
const serviceId = removedPendingMemberIds[0];
const removedMember = oldPendingMemberLookup.get(serviceId);
strictAssert(removedMember !== undefined, 'Removed member not found');
details.push({
type: 'pending-remove-one',
serviceId,
inviter: removedMember.addedByUserId,
});
}
// pendingAdminApprovalV2
(current.pendingAdminApprovalV2 || []).forEach(
currentPendingAdminAprovalMember => {
const { aci } = currentPendingAdminAprovalMember;
const oldPendingMember = oldPendingAdminApprovalLookup.get(aci);
if (aci === ourAci) {
areWePendingApproval = true;
}
if (!oldPendingMember) {
details.push({
type: 'admin-approval-add-one',
aci,
});
}
// This deletion makes it easier to capture removals
oldPendingAdminApprovalLookup.delete(aci);
}
);
// 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 = Array.from(
oldPendingAdminApprovalLookup.keys()
);
removedPendingAdminApprovalIds.forEach(aci => {
details.push({
type: 'admin-approval-remove-one',
aci,
});
});
// announcementsOnly
if (Boolean(old.announcementsOnly) !== Boolean(current.announcementsOnly)) {
details.push({
type: 'announcements-only',
announcementsOnly: Boolean(current.announcementsOnly),
});
}
// Note: currently no diff generated for bannedMembersV2 changes
// final processing
let message: GroupChangeMessageType | undefined;
let timerNotification: GroupChangeMessageType | undefined;
const firstUpdate = !isNumber(old.revision);
const isFromUs = ourAci === sourceServiceId;
const justJoinedGroup = !firstUpdate && !didWeStartInGroup && areWeInGroup;
const from =
(sourceServiceId &&
isPniString(sourceServiceId) &&
pniToAciMap.get(sourceServiceId)) ||
sourceServiceId;
// Here we hardcode initial messages if this is our first time processing data for this
// group. Ideally we can collapse it down to just one of: 'you were added',
// 'you were invited', or 'you created.'
if (firstUpdate && serviceIdKindInvitedToGroup !== undefined) {
// Note, we will add 'you were invited' to group even if dropInitialJoinMessage = true
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from: whoInvitedUsUserId || from,
details: [
{
type: 'pending-add-one',
serviceId: window.storage.user.getCheckedServiceId(
serviceIdKindInvitedToGroup
),
},
],
},
readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
} else if (firstUpdate && areWePendingApproval) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from: ourAci,
details: [
{
type: 'admin-approval-add-one',
aci: ourAci,
},
],
},
};
} else if (firstUpdate && dropInitialJoinMessage) {
// None of the rest of the messages should be added if dropInitialJoinMessage = true
message = undefined;
} else if (
firstUpdate &&
current.revision === 0 &&
sourceServiceId === ourAci
) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from,
details: [
{
type: 'create',
},
],
},
readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
} else if (firstUpdate && areWeInGroup) {
const filteredDetails = keepOnlyOurAdds(details);
strictAssert(
filteredDetails.length === 1,
'extractDiffs/firstUpdate: Should be only one self-add!'
);
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from,
details: filteredDetails,
},
readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
} else if (firstUpdate && current.revision === 0) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from,
details: [
{
type: 'create',
},
],
},
readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
} else if (justJoinedGroup) {
const filteredDetails = keepOnlyOurAdds(details);
strictAssert(
filteredDetails.length === 1,
'extractDiffs/justJoinedGroup: Should be only one self-add!'
);
// If we've dropped other changes, we collapse them into a single summary
if (details.length > 1) {
filteredDetails.push({
type: 'summary',
});
}
message = {
...generateBasicMessage(),
type: 'group-v2-change',
sourceServiceId,
groupV2Change: {
from,
details: filteredDetails,
},
readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
} else if (details.length > 0) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
sourceServiceId,
groupV2Change: {
from,
details,
},
readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
}
// 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)
) {
const expireTimer = current.expireTimer || DurationInSeconds.ZERO;
log.info(
`extractDiffs/${logId}: generating change notification for new ${expireTimer} timer`
);
timerNotification = {
...generateBasicMessage(),
type: 'timer-notification',
sourceServiceId,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: {
expireTimer,
sourceServiceId,
},
};
}
const result = compact([message, timerNotification]);
log.info(
`extractDiffs/${logId} complete, generated ${result.length} change messages`
);
return result;
}
function profileKeysToMap(items: ReadonlyArray<GroupChangeMemberType>) {
const map = new Map<AciString, string>();
for (const { aci, profileKey } of items) {
map.set(aci, Bytes.toBase64(profileKey));
}
return map;
}
type GroupChangeMemberType = {
profileKey: Uint8Array;
aci: AciString;
};
type GroupApplyResultType = {
newAttributes: ConversationAttributesType;
newProfileKeys: Array<GroupChangeMemberType>;
otherChanges: boolean;
};
type GroupApplyChangeResultType = GroupApplyResultType & {
promotedAciToPniMap: Map<AciString, PniString>;
};
async function applyGroupChange({
actions,
group,
sourceServiceId,
}: {
actions: DecryptedGroupChangeActions;
group: ConversationAttributesType;
sourceServiceId: ServiceIdString;
}): Promise<GroupApplyChangeResultType> {
const logId = idForLogging(group.groupId);
const ourAci = window.storage.user.getCheckedAci();
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const version = actions.version || 0;
const result = { ...group };
const newProfileKeys: Array<GroupChangeMemberType> = [];
const promotedAciToPniMap = new Map<AciString, PniString>();
const members: Record<AciString, GroupV2MemberType> = fromPairs(
(result.membersV2 || []).map(member => [member.aci, member])
);
const pendingMembers: Record<ServiceIdString, GroupV2PendingMemberType> =
fromPairs(
(result.pendingMembersV2 || []).map(member => [member.serviceId, member])
);
const pendingAdminApprovalMembers: Record<
AciString,
GroupV2PendingAdminApprovalType
> = fromPairs(
(result.pendingAdminApprovalV2 || []).map(member => [member.aci, member])
);
const bannedMembers = new Map<ServiceIdString, GroupV2BannedMemberType>(
(result.bannedMembersV2 || []).map(member => [member.serviceId, member])
);
if (result.temporaryMemberCount) {
log.warn(
`applyGroupChange(${logId}): temporaryMemberCount is set, and should not be!`
);
}
// version?: number;
result.revision = version;
// addMembers?: Array<GroupChange.Actions.AddMemberAction>;
(actions.addMembers || []).forEach(addMember => {
const { added } = addMember;
if (!added || !added.userId) {
throw new Error('applyGroupChange: addMember.added is missing');
}
const addedUuid = added.userId;
if (members[addedUuid]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add member failed; already in members.`
);
return;
}
members[addedUuid] = {
aci: addedUuid,
role: added.role || MEMBER_ROLE_ENUM.DEFAULT,
joinedAtVersion: version,
joinedFromLink: addMember.joinFromInviteLink || false,
};
if (pendingMembers[addedUuid]) {
log.warn(
`applyGroupChange/${logId}: Removing newly-added member from pendingMembers.`
);
delete pendingMembers[addedUuid];
}
// Capture who added us
if (ourAci && sourceServiceId && addedUuid === ourAci) {
result.addedBy = sourceServiceId;
}
if (added.profileKey) {
newProfileKeys.push({
profileKey: added.profileKey,
aci: added.userId,
});
}
});
// deleteMembers?: Array<GroupChange.Actions.DeleteMemberAction>;
(actions.deleteMembers || []).forEach(deleteMember => {
const { deletedUserId } = deleteMember;
if (!deletedUserId) {
throw new Error(
'applyGroupChange: deleteMember.deletedUserId is missing'
);
}
if (members[deletedUserId]) {
delete members[deletedUserId];
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to remove member failed; was not in members.`
);
}
});
// modifyMemberRoles?: Array<GroupChange.Actions.ModifyMemberRoleAction>;
(actions.modifyMemberRoles || []).forEach(modifyMemberRole => {
const { role, userId } = modifyMemberRole;
if (!role || !userId) {
throw new Error('applyGroupChange: modifyMemberRole had a missing value');
}
if (members[userId]) {
members[userId] = {
...members[userId],
role,
};
} else {
throw new Error(
'applyGroupChange: modifyMemberRole tried to modify nonexistent member'
);
}
});
// modifyMemberProfileKeys?:
// Array<GroupChange.Actions.ModifyMemberProfileKeyAction>;
(actions.modifyMemberProfileKeys || []).forEach(modifyMemberProfileKey => {
const { profileKey, aci } = modifyMemberProfileKey;
if (!profileKey || !aci) {
throw new Error(
'applyGroupChange: modifyMemberProfileKey had a missing value'
);
}
if (aci === sourceServiceId || !hasProfileKey(aci)) {
newProfileKeys.push({
profileKey,
aci,
});
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to modify member profile key ` +
'failed; sourceServiceId is not the same as change aci'
);
}
});
// addPendingMembers?: Array<
// GroupChange.Actions.AddMemberPendingProfileKeyAction
// >;
(actions.addPendingMembers || []).forEach(addPendingMember => {
const { added } = addPendingMember;
if (!added || !added.member || !added.member.userId) {
throw new Error(
'applyGroupChange: addPendingMembers had a missing value'
);
}
const addedUserId = added.member.userId;
if (isAciString(addedUserId) && members[addedUserId]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add pendingMember failed; was already in members.`
);
return;
}
if (pendingMembers[addedUserId]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add pendingMember failed; was already in pendingMembers.`
);
return;
}
pendingMembers[addedUserId] = {
serviceId: addedUserId,
addedByUserId: added.addedByUserId,
timestamp: added.timestamp,
role: added.member.role || MEMBER_ROLE_ENUM.DEFAULT,
};
});
// deletePendingMembers?: Array<
// GroupChange.Actions.DeleteMemberPendingProfileKeyAction
// >;
(actions.deletePendingMembers || []).forEach(deletePendingMember => {
const { deletedUserId } = deletePendingMember;
if (!deletedUserId) {
throw new Error(
'applyGroupChange: deletePendingMember.deletedUserId is null!'
);
}
if (pendingMembers[deletedUserId]) {
delete pendingMembers[deletedUserId];
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to remove pendingMember failed; was not in pendingMembers.`
);
}
});
// promotePendingMembers?: Array<
// GroupChange.Actions.PromoteMemberPendingProfileKeyAction
// >;
(actions.promotePendingMembers || []).forEach(promotePendingMember => {
const { profileKey, aci } = promotePendingMember;
if (!profileKey || !aci) {
throw new Error(
'applyGroupChange: promotePendingMember had a missing value'
);
}
const previousRecord = pendingMembers[aci];
if (pendingMembers[aci]) {
delete pendingMembers[aci];
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to promote pendingMember failed; was not in pendingMembers.`
);
}
if (members[aci]) {
log.warn(
`applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.`
);
return;
}
members[aci] = {
aci,
joinedAtVersion: version,
role: previousRecord.role || MEMBER_ROLE_ENUM.DEFAULT,
};
newProfileKeys.push({
profileKey,
aci,
});
});
// promoteMembersPendingPniAciProfileKey?: Array<
// GroupChange.Actions.PromoteMemberPendingPniAciProfileKeyAction
// >;
(actions.promoteMembersPendingPniAciProfileKey || []).forEach(
promotePendingMember => {
const { profileKey, aci, pni } = promotePendingMember;
if (!profileKey || !aci || !pni) {
throw new Error(
'applyGroupChange: promotePendingMember had a missing value'
);
}
const previousRecord = pendingMembers[pni];
promotedAciToPniMap.set(aci, pni);
if (pendingMembers[pni]) {
delete pendingMembers[pni];
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to promote pendingMember failed; was not in pendingMembers.`
);
}
if (members[aci]) {
log.warn(
`applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.`
);
return;
}
members[aci] = {
aci,
joinedAtVersion: version,
role: previousRecord.role || MEMBER_ROLE_ENUM.DEFAULT,
};
newProfileKeys.push({
profileKey,
aci,
});
}
);
// modifyTitle?: GroupChange.Actions.ModifyTitleAction;
if (actions.modifyTitle) {
const { title } = actions.modifyTitle;
if (title && title.content === 'title') {
result.name = dropNull(title.title);
} else {
log.warn(
`applyGroupChange/${logId}: Clearing group title due to missing data.`
);
result.name = undefined;
}
}
// modifyAvatar?: GroupChange.Actions.ModifyAvatarAction;
if (actions.modifyAvatar) {
const { avatar } = actions.modifyAvatar;
await applyNewAvatar(dropNull(avatar), result, logId);
}
// modifyDisappearingMessagesTimer?:
// GroupChange.Actions.ModifyDisappearingMessagesTimerAction;
if (actions.modifyDisappearingMessagesTimer) {
const disappearingMessagesTimer: Proto.GroupAttributeBlob | undefined =
actions.modifyDisappearingMessagesTimer.timer;
if (
disappearingMessagesTimer &&
disappearingMessagesTimer.content === 'disappearingMessagesDuration'
) {
const duration = disappearingMessagesTimer.disappearingMessagesDuration;
result.expireTimer =
duration == null ? undefined : DurationInSeconds.fromSeconds(duration);
} else {
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,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
};
// modifyAttributesAccess?:
// GroupChange.Actions.ModifyAttributesAccessControlAction;
if (actions.modifyAttributesAccess) {
result.accessControl = {
...result.accessControl,
attributes:
actions.modifyAttributesAccess.attributesAccess || ACCESS_ENUM.MEMBER,
};
}
// modifyMemberAccess?: GroupChange.Actions.ModifyMembersAccessControlAction;
if (actions.modifyMemberAccess) {
result.accessControl = {
...result.accessControl,
members: actions.modifyMemberAccess.membersAccess || ACCESS_ENUM.MEMBER,
};
}
// modifyAddFromInviteLinkAccess?:
// GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction;
if (actions.modifyAddFromInviteLinkAccess) {
result.accessControl = {
...result.accessControl,
addFromInviteLink:
actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess ||
ACCESS_ENUM.UNSATISFIABLE,
};
}
// addMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.AddMemberPendingAdminApprovalAction
// >;
(actions.addMemberPendingAdminApprovals || []).forEach(
pendingAdminApproval => {
const { added } = pendingAdminApproval;
if (!added) {
throw new Error(
'applyGroupChange: modifyMemberProfileKey had a missing value'
);
}
if (members[added.userId]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in members.`
);
return;
}
if (pendingMembers[added.userId]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in pendingMembers.`
);
return;
}
if (pendingAdminApprovalMembers[added.userId]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in pendingAdminApprovalMembers.`
);
return;
}
pendingAdminApprovalMembers[added.userId] = {
aci: added.userId,
timestamp: added.timestamp,
};
if (added.profileKey) {
newProfileKeys.push({
profileKey: added.profileKey,
aci: added.userId,
});
}
}
);
// deleteMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.DeleteMemberPendingAdminApprovalAction
// >;
(actions.deleteMemberPendingAdminApprovals || []).forEach(
deleteAdminApproval => {
const { deletedUserId } = deleteAdminApproval;
if (!deletedUserId) {
throw new Error(
'applyGroupChange: deleteAdminApproval.deletedUserId is null!'
);
}
if (pendingAdminApprovalMembers[deletedUserId]) {
delete pendingAdminApprovalMembers[deletedUserId];
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to remove pendingAdminApproval failed; was not in pendingAdminApprovalMembers.`
);
}
}
);
// promoteMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.PromoteMemberPendingAdminApprovalAction
// >;
(actions.promoteMemberPendingAdminApprovals || []).forEach(
promoteAdminApproval => {
const { userId, role } = promoteAdminApproval;
if (!userId) {
throw new Error(
'applyGroupChange: promoteAdminApproval had a missing value'
);
}
if (pendingAdminApprovalMembers[userId]) {
delete pendingAdminApprovalMembers[userId];
} else {
log.warn(
`applyGroupChange/${logId}: Attempt to promote pendingAdminApproval failed; was not in pendingAdminApprovalMembers.`
);
}
if (pendingMembers[userId]) {
delete pendingAdminApprovalMembers[userId];
log.warn(
`applyGroupChange/${logId}: Deleted pendingAdminApproval from pendingMembers.`
);
}
if (members[userId]) {
log.warn(
`applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.`
);
return;
}
members[userId] = {
aci: userId,
joinedAtVersion: version,
role: role || MEMBER_ROLE_ENUM.DEFAULT,
approvedByAdmin: true,
};
}
);
// modifyInviteLinkPassword?: GroupChange.Actions.ModifyInviteLinkPasswordAction;
if (actions.modifyInviteLinkPassword) {
const { inviteLinkPassword } = actions.modifyInviteLinkPassword;
if (inviteLinkPassword) {
result.groupInviteLinkPassword = inviteLinkPassword;
} else {
result.groupInviteLinkPassword = undefined;
}
}
// modifyDescription?: GroupChange.Actions.ModifyDescriptionAction;
if (actions.modifyDescription) {
const { descriptionBytes } = actions.modifyDescription;
if (descriptionBytes && descriptionBytes.content === 'descriptionText') {
result.description = dropNull(descriptionBytes.descriptionText);
} else {
log.warn(
`applyGroupChange/${logId}: Clearing group description due to missing data.`
);
result.description = undefined;
}
}
if (actions.modifyAnnouncementsOnly) {
const { announcementsOnly } = actions.modifyAnnouncementsOnly;
result.announcementsOnly = announcementsOnly;
}
if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
actions.addMembersBanned.forEach(member => {
if (bannedMembers.has(member.serviceId)) {
log.warn(
`applyGroupChange/${logId}: Attempt to add banned member failed; was already in banned list.`
);
return;
}
bannedMembers.set(member.serviceId, member);
});
}
if (actions.deleteMembersBanned && actions.deleteMembersBanned.length > 0) {
actions.deleteMembersBanned.forEach(serviceId => {
if (!bannedMembers.has(serviceId)) {
log.warn(
`applyGroupChange/${logId}: Attempt to remove banned member failed; was not in banned list.`
);
return;
}
bannedMembers.delete(serviceId);
});
}
if (ourAci) {
result.left = !members[ourAci];
}
if (result.left) {
result.addedBy = undefined;
}
// Go from lookups back to arrays
result.membersV2 = values(members);
result.pendingMembersV2 = values(pendingMembers);
result.pendingAdminApprovalV2 = values(pendingAdminApprovalMembers);
result.bannedMembersV2 = Array.from(bannedMembers.values());
return {
newAttributes: result,
newProfileKeys,
otherChanges: false,
promotedAciToPniMap,
};
}
export async function decryptGroupAvatar(
avatarKey: string,
secretParamsBase64: string
): Promise<Uint8Array> {
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 = Proto.GroupAttributeBlob.decode(plaintext);
if (blob.content !== 'avatar') {
throw new Error(
`decryptGroupAvatar: Returned blob had incorrect content: ${blob.content}`
);
}
const avatar = dropNull(blob.avatar);
if (!avatar) {
throw new Error('decryptGroupAvatar: Returned blob had no avatar set!');
}
return avatar;
}
// Overwriting result.avatar as part of functionality
/* eslint-disable no-param-reassign */
export async function applyNewAvatar(
newAvatar: string | undefined,
result: Pick<ConversationAttributesType, 'avatar' | 'secretParams'>,
logId: string
): Promise<void> {
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!');
}
const data = await decryptGroupAvatar(newAvatar, result.secretParams);
const hash = computeHash(data);
if (result.avatar?.hash === hash) {
log.info(
`applyNewAvatar/${logId}: Hash is the same, but url was different. Saving new url.`
);
result.avatar = {
...result.avatar,
url: newAvatar,
};
return;
}
if (result.avatar) {
await window.Signal.Migrations.deleteAttachmentData(result.avatar.path);
}
const path = await window.Signal.Migrations.writeNewAttachmentData(data);
result.avatar = {
url: newAvatar,
path,
hash,
};
}
} catch (error) {
log.warn(
`applyNewAvatar/${logId} Failed to handle avatar, clearing it`,
Errors.toLogFormat(error)
);
if (result.avatar && result.avatar.path) {
await window.Signal.Migrations.deleteAttachmentData(result.avatar.path);
}
result.avatar = undefined;
}
}
/* eslint-enable no-param-reassign */
function profileKeyHasChanged(
userId: ServiceIdString,
newProfileKey: Uint8Array
) {
const conversation = window.ConversationController.get(userId);
if (!conversation) {
return true;
}
const existingBase64 = conversation.get('profileKey');
if (!existingBase64) {
return true;
}
const newBase64 = Bytes.toBase64(newProfileKey);
return newBase64 !== existingBase64;
}
function hasProfileKey(userId: ServiceIdString) {
const conversation = window.ConversationController.get(userId);
if (!conversation) {
return false;
}
const existingBase64 = conversation.get('profileKey');
return existingBase64 !== undefined;
}
async function applyGroupState({
group,
groupState,
sourceServiceId,
}: {
group: ConversationAttributesType;
groupState: DecryptedGroupState;
sourceServiceId?: ServiceIdString;
}): Promise<GroupApplyResultType> {
const logId = idForLogging(group.groupId);
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const MEMBER_ROLE_ENUM = Proto.Member.Role;
const version = groupState.version || 0;
const result = { ...group };
const newProfileKeys: Array<GroupChangeMemberType> = [];
// Used to capture changes not already expressed in group notifications or profile keys
let otherChanges = false;
// Used to detect changes in these lists
const members: Record<string, GroupV2MemberType> = fromPairs(
(result.membersV2 || []).map(member => [member.aci, member])
);
const pendingMembers: Record<string, GroupV2PendingMemberType> = fromPairs(
(result.pendingMembersV2 || []).map(member => [member.serviceId, member])
);
const pendingAdminApprovalMembers: Record<
string,
GroupV2PendingAdminApprovalType
> = fromPairs(
(result.pendingAdminApprovalV2 || []).map(member => [member.aci, member])
);
const bannedMembers = new Map<string, GroupV2BannedMemberType>(
(result.bannedMembersV2 || []).map(member => [member.serviceId, member])
);
// version
result.revision = version;
// title
// Note: During decryption, title becomes a GroupAttributeBlob
const { title } = groupState;
if (title && title.content === 'title') {
result.name = dropNull(title.title);
} else {
result.name = undefined;
}
// avatar
await applyNewAvatar(dropNull(groupState.avatar), result, logId);
// disappearingMessagesTimer
// Note: during decryption, disappearingMessageTimer becomes a GroupAttributeBlob
const { disappearingMessagesTimer } = groupState;
if (
disappearingMessagesTimer &&
disappearingMessagesTimer.content === 'disappearingMessagesDuration'
) {
const duration = disappearingMessagesTimer.disappearingMessagesDuration;
result.expireTimer =
duration == null ? undefined : DurationInSeconds.fromSeconds(duration);
} else {
result.expireTimer = undefined;
}
// accessControl
const { accessControl } = groupState;
result.accessControl = {
attributes:
(accessControl && accessControl.attributes) || ACCESS_ENUM.MEMBER,
members: (accessControl && accessControl.members) || ACCESS_ENUM.MEMBER,
addFromInviteLink:
(accessControl && accessControl.addFromInviteLink) ||
ACCESS_ENUM.UNSATISFIABLE,
};
// Optimization: we assume we have left the group unless we are found in members
result.left = true;
const ourAci = window.storage.user.getCheckedAci();
// members
const wasPreviouslyAMember = (result.membersV2 || []).some(
item => item.aci !== ourAci
);
if (groupState.members) {
result.membersV2 = groupState.members.map(member => {
if (member.userId === ourAci) {
result.left = false;
// Capture who added us if we were previously not in group
if (
sourceServiceId &&
!wasPreviouslyAMember &&
isNumber(member.joinedAtVersion) &&
member.joinedAtVersion === version
) {
result.addedBy = sourceServiceId;
}
}
if (!isValidRole(member.role)) {
throw new Error(
`applyGroupState: Member had invalid role ${member.role}`
);
}
const previousMember = members[member.userId];
if (member.profileKey && !hasProfileKey(member.userId)) {
newProfileKeys.push({
profileKey: member.profileKey,
aci: member.userId,
});
} else if (
member.profileKey &&
profileKeyHasChanged(member.userId, member.profileKey)
) {
log.warn(
`applyGroupState(${logId}): Member ${member.userId} had different profileKey`
);
otherChanges = true;
} else if (!previousMember) {
otherChanges = true;
}
if (
previousMember &&
previousMember.joinedAtVersion !== member.joinedAtVersion
) {
otherChanges = true;
log.warn(
`applyGroupState(${logId}): Member ${member.userId} had different joinedAtVersion`
);
}
// Note: role changes will be reflected in group update messages
return {
role: member.role || MEMBER_ROLE_ENUM.DEFAULT,
joinedAtVersion: member.joinedAtVersion,
aci: member.userId,
};
});
}
// membersPendingProfileKey
if (groupState.membersPendingProfileKey) {
result.pendingMembersV2 = groupState.membersPendingProfileKey.map(
member => {
if (!member.member || !member.member.userId) {
throw new Error(
'applyGroupState: Member pending profile key did not have an associated userId'
);
}
if (!member.addedByUserId) {
throw new Error(
'applyGroupState: Member pending profile key did not have an addedByUserID'
);
}
if (!isValidRole(member.member.role)) {
throw new Error(
`applyGroupState: Member pending profile key had invalid role ${member.member.role}`
);
}
const previousMember = pendingMembers[member.member.userId];
otherChanges = true;
if (
previousMember &&
previousMember.addedByUserId !== member.addedByUserId
) {
otherChanges = true;
log.warn(
`applyGroupState(${logId}): Member ${member.member.userId} had different addedByUserId`
);
}
if (previousMember && previousMember.timestamp !== member.timestamp) {
otherChanges = true;
log.warn(
`applyGroupState(${logId}): Member ${member.member.userId} had different timestamp`
);
}
if (previousMember && previousMember.role !== member.member.role) {
otherChanges = true;
log.warn(
`applyGroupState(${logId}): Member ${member.member.userId} had different role`
);
}
return {
addedByUserId: member.addedByUserId,
serviceId: member.member.userId,
timestamp: member.timestamp,
role: member.member.role || MEMBER_ROLE_ENUM.DEFAULT,
};
}
);
}
// membersPendingAdminApproval
if (groupState.membersPendingAdminApproval) {
result.pendingAdminApprovalV2 = groupState.membersPendingAdminApproval.map(
member => {
const previousMember = pendingAdminApprovalMembers[member.userId];
if (member.profileKey && !hasProfileKey(member.userId)) {
newProfileKeys.push({
profileKey: member.profileKey,
aci: member.userId,
});
} else if (
member.profileKey &&
profileKeyHasChanged(member.userId, member.profileKey)
) {
log.warn(
`applyGroupState(${logId}): Member ${member.userId} had different profileKey`
);
otherChanges = true;
} else if (!previousMember) {
otherChanges = true;
}
if (previousMember && previousMember.timestamp !== member.timestamp) {
otherChanges = true;
log.warn(
`applyGroupState(${logId}): Member ${member.userId} had different timestamp`
);
}
return {
aci: member.userId,
timestamp: member.timestamp,
};
}
);
}
// inviteLinkPassword
const { inviteLinkPassword } = groupState;
if (inviteLinkPassword) {
result.groupInviteLinkPassword = inviteLinkPassword;
} else {
result.groupInviteLinkPassword = undefined;
}
// descriptionBytes
const { descriptionBytes } = groupState;
if (descriptionBytes && descriptionBytes.content === 'descriptionText') {
result.description = dropNull(descriptionBytes.descriptionText);
} else {
result.description = undefined;
}
// announcementsOnly
result.announcementsOnly = groupState.announcementsOnly;
// membersBanned
result.bannedMembersV2 = groupState.membersBanned?.map(member => {
const previousMember = bannedMembers.get(member.serviceId);
if (!previousMember) {
otherChanges = true;
}
if (previousMember && previousMember.timestamp !== member.timestamp) {
otherChanges = true;
log.warn(
`applyGroupState(${logId}): Member ${member.serviceId} had different timestamp`
);
}
return member;
});
if (result.left) {
result.addedBy = undefined;
}
if (result.temporaryMemberCount) {
log.info(`applyGroupState(${logId}): Clearing temporaryMemberCount`);
result.temporaryMemberCount = undefined;
}
return {
newAttributes: result,
newProfileKeys,
otherChanges,
};
}
function isValidRole(role?: number): role is number {
const MEMBER_ROLE_ENUM = Proto.Member.Role;
return (
role === MEMBER_ROLE_ENUM.ADMINISTRATOR || role === MEMBER_ROLE_ENUM.DEFAULT
);
}
function isValidAccess(access?: number): access is number {
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
return access === ACCESS_ENUM.ADMINISTRATOR || access === ACCESS_ENUM.MEMBER;
}
function isValidLinkAccess(access?: number): access is number {
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
return (
access === ACCESS_ENUM.UNKNOWN ||
access === ACCESS_ENUM.ANY ||
access === ACCESS_ENUM.ADMINISTRATOR ||
access === ACCESS_ENUM.UNSATISFIABLE
);
}
function isValidProfileKey(buffer?: Uint8Array): boolean {
return Boolean(buffer && buffer.length === 32);
}
function normalizeTimestamp(timestamp: Long | null | undefined): number {
if (!timestamp) {
return 0;
}
const asNumber = timestamp.toNumber();
const now = Date.now();
if (!asNumber || asNumber > now) {
return now;
}
return asNumber;
}
type DecryptedGroupChangeActions = {
version?: number;
sourceServiceId?: ServiceIdString;
addMembers?: ReadonlyArray<{
added: DecryptedMember;
joinFromInviteLink: boolean;
}>;
deleteMembers?: ReadonlyArray<{
deletedUserId: AciString;
}>;
modifyMemberRoles?: ReadonlyArray<{
userId: AciString;
role: Proto.Member.Role;
}>;
modifyMemberProfileKeys?: ReadonlyArray<{
profileKey: Uint8Array;
aci: AciString;
}>;
addPendingMembers?: ReadonlyArray<{
added: DecryptedMemberPendingProfileKey;
}>;
deletePendingMembers?: ReadonlyArray<{
// This might be a PNI
deletedUserId: ServiceIdString;
}>;
promotePendingMembers?: ReadonlyArray<{
profileKey: Uint8Array;
aci: AciString;
}>;
promoteMembersPendingPniAciProfileKey?: ReadonlyArray<{
profileKey: Uint8Array;
aci: AciString;
pni: PniString;
}>;
modifyTitle?: {
title?: Proto.GroupAttributeBlob;
};
modifyDisappearingMessagesTimer?: {
timer?: Proto.GroupAttributeBlob;
};
addMemberPendingAdminApprovals?: ReadonlyArray<{
added: DecryptedMemberPendingAdminApproval;
}>;
deleteMemberPendingAdminApprovals?: ReadonlyArray<{
deletedUserId: AciString;
}>;
promoteMemberPendingAdminApprovals?: ReadonlyArray<{
userId: AciString;
role: Proto.Member.Role;
}>;
modifyInviteLinkPassword?: {
inviteLinkPassword?: string;
};
modifyDescription?: {
descriptionBytes?: Proto.GroupAttributeBlob;
};
modifyAnnouncementsOnly?: {
announcementsOnly: boolean;
};
addMembersBanned?: ReadonlyArray<GroupV2BannedMemberType>;
// This might be a PNI
deleteMembersBanned?: ReadonlyArray<ServiceIdString>;
} & Pick<
Proto.GroupChange.IActions,
| 'modifyAttributesAccess'
| 'modifyMemberAccess'
| 'modifyAddFromInviteLinkAccess'
| 'modifyAvatar'
>;
function decryptGroupChange(
actions: Readonly<Proto.GroupChange.IActions>,
groupSecretParams: string,
logId: string
): DecryptedGroupChangeActions {
const result: DecryptedGroupChangeActions = {
version: dropNull(actions.version),
};
const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams);
if (actions.sourceUserId && actions.sourceUserId.length !== 0) {
try {
result.sourceServiceId = decryptServiceId(
clientZkGroupCipher,
actions.sourceUserId
);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt sourceServiceId.`,
Errors.toLogFormat(error)
);
}
if (!result.sourceServiceId || !isServiceIdString(result.sourceServiceId)) {
log.warn(
`decryptGroupChange/${logId}: Invalid sourceServiceId. Clearing sourceServiceId.`
);
result.sourceServiceId = undefined;
}
} else {
throw new Error('decryptGroupChange: Missing sourceServiceId');
}
// addMembers?: Array<GroupChange.Actions.AddMemberAction>;
result.addMembers = compact(
(actions.addMembers || []).map(addMember => {
strictAssert(
addMember.added,
'decryptGroupChange: AddMember was missing added field!'
);
const decrypted = decryptMember(
clientZkGroupCipher,
addMember.added,
logId
);
if (!decrypted) {
return null;
}
return {
added: decrypted,
joinFromInviteLink: Boolean(addMember.joinFromInviteLink),
};
})
);
// deleteMembers?: Array<GroupChange.Actions.DeleteMemberAction>;
result.deleteMembers = compact(
(actions.deleteMembers || []).map(deleteMember => {
const { deletedUserId } = deleteMember;
strictAssert(
Bytes.isNotEmpty(deletedUserId),
'decryptGroupChange: deleteMember.deletedUserId was missing'
);
let userId: AciString;
try {
userId = decryptAci(clientZkGroupCipher, deletedUserId);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt deleteMembers.deletedUserId. Dropping member.`,
Errors.toLogFormat(error)
);
return null;
}
return { deletedUserId: userId };
})
);
// modifyMemberRoles?: Array<GroupChange.Actions.ModifyMemberRoleAction>;
result.modifyMemberRoles = compact(
(actions.modifyMemberRoles || []).map(modifyMember => {
strictAssert(
Bytes.isNotEmpty(modifyMember.userId),
'decryptGroupChange: modifyMemberRole.userId was missing'
);
let userId: AciString;
try {
userId = decryptAci(clientZkGroupCipher, modifyMember.userId);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt modifyMemberRole.userId. Dropping member.`,
Errors.toLogFormat(error)
);
return null;
}
const role = dropNull(modifyMember.role);
if (!isValidRole(role)) {
throw new Error(
`decryptGroupChange: modifyMemberRole had invalid role ${modifyMember.role}`
);
}
return {
role,
userId,
};
})
);
// modifyMemberProfileKeys?: Array<
// GroupChange.Actions.ModifyMemberProfileKeyAction
// >;
result.modifyMemberProfileKeys = compact(
(actions.modifyMemberProfileKeys || []).map(modifyMemberProfileKey => {
let { userId, profileKey: encryptedProfileKey } = modifyMemberProfileKey;
// TODO: DESKTOP-3816
if (Bytes.isEmpty(userId) || Bytes.isEmpty(encryptedProfileKey)) {
const { presentation } = modifyMemberProfileKey;
strictAssert(
Bytes.isNotEmpty(presentation),
'decryptGroupChange: modifyMemberProfileKeys.presentation was missing'
);
const decodedPresentation =
decodeProfileKeyCredentialPresentation(presentation);
({ userId, profileKey: encryptedProfileKey } = decodedPresentation);
}
strictAssert(
Bytes.isNotEmpty(userId),
'decryptGroupChange: modifyMemberProfileKeys.userId was missing'
);
strictAssert(
Bytes.isNotEmpty(encryptedProfileKey),
'decryptGroupChange: modifyMemberProfileKeys.profileKey was missing'
);
let aci: AciString;
let profileKey: Uint8Array;
try {
aci = decryptAci(clientZkGroupCipher, userId);
profileKey = decryptProfileKey(
clientZkGroupCipher,
encryptedProfileKey,
aci
);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt ` +
'modifyMemberProfileKeys.userId/profileKey. Dropping member.',
Errors.toLogFormat(error)
);
return null;
}
if (!isValidProfileKey(profileKey)) {
throw new Error(
'decryptGroupChange: modifyMemberProfileKey had invalid profileKey'
);
}
return { aci, profileKey };
})
);
// addPendingMembers?: Array<
// GroupChange.Actions.AddMemberPendingProfileKeyAction
// >;
result.addPendingMembers = compact(
(actions.addPendingMembers || []).map(addPendingMember => {
strictAssert(
addPendingMember.added,
'decryptGroupChange: addPendingMember was missing added field!'
);
const decrypted = decryptMemberPendingProfileKey(
clientZkGroupCipher,
addPendingMember.added,
logId
);
if (!decrypted) {
return null;
}
return {
added: decrypted,
};
})
);
// deletePendingMembers?: Array<
// GroupChange.Actions.DeleteMemberPendingProfileKeyAction
// >;
result.deletePendingMembers = compact(
(actions.deletePendingMembers || []).map(deletePendingMember => {
const { deletedUserId } = deletePendingMember;
strictAssert(
Bytes.isNotEmpty(deletedUserId),
'decryptGroupChange: deletePendingMembers.deletedUserId was missing'
);
let userId: ServiceIdString;
try {
userId = decryptServiceId(clientZkGroupCipher, deletedUserId);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt deletePendingMembers.deletedUserId. Dropping member.`,
Errors.toLogFormat(error)
);
return null;
}
if (!isServiceIdString(userId)) {
log.warn(
`decryptGroupChange/${logId}: Dropping deletePendingMember due to invalid deletedUserId`
);
return null;
}
return {
deletedUserId: userId,
};
})
);
// promotePendingMembers?: Array<
// GroupChange.Actions.PromoteMemberPendingProfileKeyAction
// >;
result.promotePendingMembers = compact(
(actions.promotePendingMembers || []).map(promotePendingMember => {
let { userId, profileKey: encryptedProfileKey } = promotePendingMember;
// TODO: DESKTOP-3816
if (Bytes.isEmpty(userId) || Bytes.isEmpty(encryptedProfileKey)) {
const { presentation } = promotePendingMember;
strictAssert(
Bytes.isNotEmpty(presentation),
'decryptGroupChange: promotePendingMember.presentation was missing'
);
const decodedPresentation =
decodeProfileKeyCredentialPresentation(presentation);
({ userId, profileKey: encryptedProfileKey } = decodedPresentation);
}
strictAssert(
Bytes.isNotEmpty(userId),
'decryptGroupChange: promotePendingMembers.userId was missing'
);
strictAssert(
Bytes.isNotEmpty(encryptedProfileKey),
'decryptGroupChange: promotePendingMembers.profileKey was missing'
);
let aci: AciString;
let profileKey: Uint8Array;
try {
aci = decryptAci(clientZkGroupCipher, userId);
profileKey = decryptProfileKey(
clientZkGroupCipher,
encryptedProfileKey,
aci
);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt ` +
'promotePendingMembers.userId/profileKey. Dropping member.',
Errors.toLogFormat(error)
);
return null;
}
if (!isValidProfileKey(profileKey)) {
throw new Error(
'decryptGroupChange: promotePendingMembers had invalid profileKey'
);
}
return { aci, profileKey };
})
);
// promoteMembersPendingPniAciProfileKey?: Array<
// GroupChange.Actions.PromoteMemberPendingPniAciProfileKeyAction
// >;
result.promoteMembersPendingPniAciProfileKey = compact(
(actions.promoteMembersPendingPniAciProfileKey || []).map(
promotePendingMember => {
strictAssert(
Bytes.isNotEmpty(promotePendingMember.userId),
'decryptGroupChange: ' +
'promoteMembersPendingPniAciProfileKey.userId was missing'
);
strictAssert(
Bytes.isNotEmpty(promotePendingMember.pni),
'decryptGroupChange: ' +
'promoteMembersPendingPniAciProfileKey.pni was missing'
);
strictAssert(
Bytes.isNotEmpty(promotePendingMember.profileKey),
'decryptGroupChange: ' +
'promoteMembersPendingPniAciProfileKey.profileKey was missing'
);
let aci: AciString;
let pni: PniString;
let profileKey: Uint8Array;
try {
aci = decryptAci(clientZkGroupCipher, promotePendingMember.userId);
pni = decryptPni(clientZkGroupCipher, promotePendingMember.pni);
profileKey = decryptProfileKey(
clientZkGroupCipher,
promotePendingMember.profileKey,
aci
);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt promoteMembersPendingPniAciProfileKey. Dropping member.`,
Errors.toLogFormat(error)
);
return null;
}
if (!isValidProfileKey(profileKey)) {
throw new Error(
'decryptGroupChange: promoteMembersPendingPniAciProfileKey ' +
'had invalid profileKey'
);
}
return {
aci,
pni,
profileKey,
};
}
)
);
// modifyTitle?: GroupChange.Actions.ModifyTitleAction;
if (actions.modifyTitle) {
const { title } = actions.modifyTitle;
if (Bytes.isNotEmpty(title)) {
try {
result.modifyTitle = {
title: Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, title)
),
};
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt modifyTitle.title`,
Errors.toLogFormat(error)
);
}
} else {
result.modifyTitle = {};
}
}
// modifyAvatar?: GroupChange.Actions.ModifyAvatarAction;
// Note: decryption happens during application of the change, on download of the avatar
result.modifyAvatar = actions.modifyAvatar;
// modifyDisappearingMessagesTimer?:
// GroupChange.Actions.ModifyDisappearingMessagesTimerAction;
if (actions.modifyDisappearingMessagesTimer) {
const { timer } = actions.modifyDisappearingMessagesTimer;
if (Bytes.isNotEmpty(timer)) {
try {
result.modifyDisappearingMessagesTimer = {
timer: Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, timer)
),
};
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt modifyDisappearingMessagesTimer.timer`,
Errors.toLogFormat(error)
);
}
} else {
result.modifyDisappearingMessagesTimer = {};
}
}
// modifyAttributesAccess?:
// GroupChange.Actions.ModifyAttributesAccessControlAction;
if (actions.modifyAttributesAccess) {
const attributesAccess = dropNull(
actions.modifyAttributesAccess.attributesAccess
);
strictAssert(
isValidAccess(attributesAccess),
`decryptGroupChange: modifyAttributesAccess.attributesAccess was not valid: ${actions.modifyAttributesAccess.attributesAccess}`
);
result.modifyAttributesAccess = {
attributesAccess,
};
}
// modifyMemberAccess?: GroupChange.Actions.ModifyMembersAccessControlAction;
if (actions.modifyMemberAccess) {
const membersAccess = dropNull(actions.modifyMemberAccess.membersAccess);
strictAssert(
isValidAccess(membersAccess),
`decryptGroupChange: modifyMemberAccess.membersAccess was not valid: ${actions.modifyMemberAccess.membersAccess}`
);
result.modifyMemberAccess = {
membersAccess,
};
}
// modifyAddFromInviteLinkAccess?:
// GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction;
if (actions.modifyAddFromInviteLinkAccess) {
const addFromInviteLinkAccess = dropNull(
actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess
);
strictAssert(
isValidLinkAccess(addFromInviteLinkAccess),
`decryptGroupChange: modifyAddFromInviteLinkAccess.addFromInviteLinkAccess was not valid: ${actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess}`
);
result.modifyAddFromInviteLinkAccess = {
addFromInviteLinkAccess,
};
}
// addMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.AddMemberPendingAdminApprovalAction
// >;
result.addMemberPendingAdminApprovals = compact(
(actions.addMemberPendingAdminApprovals || []).map(
addPendingAdminApproval => {
const { added } = addPendingAdminApproval;
strictAssert(
added,
'decryptGroupChange: addPendingAdminApproval was missing added field!'
);
const decrypted = decryptMemberPendingAdminApproval(
clientZkGroupCipher,
added,
logId
);
if (!decrypted) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt addPendingAdminApproval.added. Dropping member.`
);
return null;
}
return { added: decrypted };
}
)
);
// deleteMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.DeleteMemberPendingAdminApprovalAction
// >;
result.deleteMemberPendingAdminApprovals = compact(
(actions.deleteMemberPendingAdminApprovals || []).map(
deletePendingApproval => {
const { deletedUserId } = deletePendingApproval;
strictAssert(
Bytes.isNotEmpty(deletedUserId),
'decryptGroupChange: deletePendingApproval.deletedUserId was missing'
);
let aci: AciString;
try {
aci = decryptAci(clientZkGroupCipher, deletedUserId);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt deletePendingApproval.deletedUserId. Dropping member.`,
Errors.toLogFormat(error)
);
return null;
}
return { deletedUserId: aci };
}
)
);
// promoteMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.PromoteMemberPendingAdminApprovalAction
// >;
result.promoteMemberPendingAdminApprovals = compact(
(actions.promoteMemberPendingAdminApprovals || []).map(
promoteAdminApproval => {
const { userId } = promoteAdminApproval;
strictAssert(
Bytes.isNotEmpty(userId),
'decryptGroupChange: promoteAdminApproval.userId was missing'
);
let decryptedUserId: AciString;
try {
decryptedUserId = decryptAci(clientZkGroupCipher, userId);
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt promoteAdminApproval.userId. Dropping member.`,
Errors.toLogFormat(error)
);
return null;
}
const role = dropNull(promoteAdminApproval.role);
if (!isValidRole(role)) {
throw new Error(
`decryptGroupChange: promoteAdminApproval had invalid role ${promoteAdminApproval.role}`
);
}
return { role, userId: decryptedUserId };
}
)
);
// modifyInviteLinkPassword?: GroupChange.Actions.ModifyInviteLinkPasswordAction;
if (actions.modifyInviteLinkPassword) {
const { inviteLinkPassword: password } = actions.modifyInviteLinkPassword;
if (Bytes.isNotEmpty(password)) {
result.modifyInviteLinkPassword = {
inviteLinkPassword: Bytes.toBase64(password),
};
} else {
result.modifyInviteLinkPassword = {};
}
}
// modifyDescription?: GroupChange.Actions.ModifyDescriptionAction;
if (actions.modifyDescription) {
const { descriptionBytes } = actions.modifyDescription;
if (Bytes.isNotEmpty(descriptionBytes)) {
try {
result.modifyDescription = {
descriptionBytes: Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, descriptionBytes)
),
};
} catch (error) {
log.warn(
`decryptGroupChange/${logId}: Unable to decrypt modifyDescription.descriptionBytes`,
Errors.toLogFormat(error)
);
}
} else {
result.modifyDescription = {};
}
}
// modifyAnnouncementsOnly
if (actions.modifyAnnouncementsOnly) {
const { announcementsOnly } = actions.modifyAnnouncementsOnly;
result.modifyAnnouncementsOnly = {
announcementsOnly: Boolean(announcementsOnly),
};
}
// addMembersBanned
if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
result.addMembersBanned = actions.addMembersBanned
.map(item => {
if (!item.added || !item.added.userId) {
log.warn(
`decryptGroupChange/${logId}: addMembersBanned had a blank entry`
);
return null;
}
const serviceId = decryptServiceId(
clientZkGroupCipher,
item.added.userId
);
const timestamp = normalizeTimestamp(item.added.timestamp);
return { serviceId, timestamp };
})
.filter(isNotNil);
}
// deleteMembersBanned
if (actions.deleteMembersBanned && actions.deleteMembersBanned.length > 0) {
result.deleteMembersBanned = actions.deleteMembersBanned
.map(item => {
if (!item.deletedUserId) {
log.warn(
`decryptGroupChange/${logId}: deleteMembersBanned had a blank entry`
);
return null;
}
return decryptServiceId(clientZkGroupCipher, item.deletedUserId);
})
.filter(isNotNil);
}
return result;
}
export function decryptGroupTitle(
title: Uint8Array | undefined,
secretParams: string
): string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
if (!title || !title.length) {
return undefined;
}
const blob = Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, title)
);
if (blob && blob.content === 'title') {
return dropNull(blob.title);
}
return undefined;
}
export function decryptGroupDescription(
description: Uint8Array | undefined,
secretParams: string
): string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
if (!description || !description.length) {
return undefined;
}
const blob = Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, description)
);
if (blob && blob.content === 'descriptionText') {
return dropNull(blob.descriptionText);
}
return undefined;
}
type DecryptedGroupState = {
title?: Proto.GroupAttributeBlob;
disappearingMessagesTimer?: Proto.GroupAttributeBlob;
accessControl?: {
attributes: number;
members: number;
addFromInviteLink: number;
};
version?: number;
members?: ReadonlyArray<DecryptedMember>;
membersPendingProfileKey?: ReadonlyArray<DecryptedMemberPendingProfileKey>;
membersPendingAdminApproval?: ReadonlyArray<DecryptedMemberPendingAdminApproval>;
inviteLinkPassword?: string;
descriptionBytes?: Proto.GroupAttributeBlob;
avatar?: string;
announcementsOnly?: boolean;
membersBanned?: Array<GroupV2BannedMemberType>;
};
function decryptGroupState(
groupState: Readonly<Proto.IGroup>,
groupSecretParams: string,
logId: string
): DecryptedGroupState {
const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams);
const result: DecryptedGroupState = {};
// title
if (Bytes.isNotEmpty(groupState.title)) {
try {
result.title = Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, groupState.title)
);
} catch (error) {
log.warn(
`decryptGroupState/${logId}: Unable to decrypt title. Clearing it.`,
Errors.toLogFormat(error)
);
}
}
// avatar
// Note: decryption happens during application of the change, on download of the avatar
// disappearing message timer
if (
groupState.disappearingMessagesTimer &&
groupState.disappearingMessagesTimer.length
) {
try {
result.disappearingMessagesTimer = Proto.GroupAttributeBlob.decode(
decryptGroupBlob(
clientZkGroupCipher,
groupState.disappearingMessagesTimer
)
);
} catch (error) {
log.warn(
`decryptGroupState/${logId}: Unable to decrypt disappearing message timer. Clearing it.`,
Errors.toLogFormat(error)
);
}
}
// accessControl
{
const { accessControl } = groupState;
strictAssert(accessControl, 'No accessControl field found');
const attributes =
accessControl.attributes ?? Proto.AccessControl.AccessRequired.UNKNOWN;
const members =
accessControl.members ?? Proto.AccessControl.AccessRequired.UNKNOWN;
const addFromInviteLink =
accessControl.addFromInviteLink ??
Proto.AccessControl.AccessRequired.UNKNOWN;
strictAssert(
isValidAccess(attributes),
`decryptGroupState: Access control for attributes is invalid: ${attributes}`
);
strictAssert(
isValidAccess(members),
`decryptGroupState: Access control for members is invalid: ${members}`
);
strictAssert(
isValidLinkAccess(addFromInviteLink),
`decryptGroupState: Access control for invite link is invalid: ${addFromInviteLink}`
);
result.accessControl = {
attributes,
members,
addFromInviteLink,
};
}
// version
const version = groupState.version ?? 0;
strictAssert(
isNumber(version),
`decryptGroupState: Expected version to be a number or null; it was ${groupState.version}`
);
result.version = version;
// members
if (groupState.members) {
result.members = compact(
groupState.members.map((member: Proto.IMember) =>
decryptMember(clientZkGroupCipher, member, logId)
)
);
}
// membersPendingProfileKey
if (groupState.membersPendingProfileKey) {
result.membersPendingProfileKey = compact(
groupState.membersPendingProfileKey.map(
(member: Proto.IMemberPendingProfileKey) =>
decryptMemberPendingProfileKey(clientZkGroupCipher, member, logId)
)
);
}
// membersPendingAdminApproval
if (groupState.membersPendingAdminApproval) {
result.membersPendingAdminApproval = compact(
groupState.membersPendingAdminApproval.map(
(member: Proto.IMemberPendingAdminApproval) =>
decryptMemberPendingAdminApproval(clientZkGroupCipher, member, logId)
)
);
}
// inviteLinkPassword
if (Bytes.isNotEmpty(groupState.inviteLinkPassword)) {
result.inviteLinkPassword = Bytes.toBase64(groupState.inviteLinkPassword);
}
// descriptionBytes
if (Bytes.isNotEmpty(groupState.descriptionBytes)) {
try {
result.descriptionBytes = Proto.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, groupState.descriptionBytes)
);
} catch (error) {
log.warn(
`decryptGroupState/${logId}: Unable to decrypt descriptionBytes. Clearing it.`,
Errors.toLogFormat(error)
);
}
}
// announcementsOnly
const { announcementsOnly } = groupState;
result.announcementsOnly = Boolean(announcementsOnly);
// membersBanned
const { membersBanned } = groupState;
if (membersBanned && membersBanned.length > 0) {
result.membersBanned = membersBanned
.map(item => {
if (!item.userId) {
log.warn(
`decryptGroupState/${logId}: membersBanned had a blank entry`
);
return null;
}
const serviceId = decryptServiceId(clientZkGroupCipher, item.userId);
const timestamp = item.timestamp?.toNumber() ?? 0;
return { serviceId, timestamp };
})
.filter(isNotNil);
} else {
result.membersBanned = [];
}
result.avatar = dropNull(groupState.avatar);
return result;
}
type DecryptedMember = Readonly<{
userId: AciString;
profileKey: Uint8Array;
role: Proto.Member.Role;
joinedAtVersion: number;
}>;
function decryptMember(
clientZkGroupCipher: ClientZkGroupCipher,
member: Readonly<Proto.IMember>,
logId: string
): DecryptedMember | undefined {
// userId
strictAssert(
Bytes.isNotEmpty(member.userId),
'decryptMember: Member had missing userId'
);
let userId: AciString;
try {
userId = decryptAci(clientZkGroupCipher, member.userId);
} catch (error) {
log.warn(
`decryptMember/${logId}: Unable to decrypt member userid. Dropping member.`,
Errors.toLogFormat(error)
);
return undefined;
}
// profileKey
strictAssert(
Bytes.isNotEmpty(member.profileKey),
'decryptMember: Member had missing profileKey'
);
const profileKey = decryptProfileKey(
clientZkGroupCipher,
member.profileKey,
userId
);
if (!isValidProfileKey(profileKey)) {
throw new Error('decryptMember: Member had invalid profileKey');
}
// role
const role = dropNull(member.role);
if (!isValidRole(role)) {
throw new Error(`decryptMember: Member had invalid role ${member.role}`);
}
return {
userId,
profileKey,
role,
joinedAtVersion: dropNull(member.joinedAtVersion) ?? 0,
};
}
type DecryptedMemberPendingProfileKey = {
addedByUserId: AciString;
timestamp: number;
member: {
userId: ServiceIdString;
role?: Proto.Member.Role;
};
};
function decryptMemberPendingProfileKey(
clientZkGroupCipher: ClientZkGroupCipher,
member: Readonly<Proto.IMemberPendingProfileKey>,
logId: string
): DecryptedMemberPendingProfileKey | undefined {
// addedByUserId
strictAssert(
Bytes.isNotEmpty(member.addedByUserId),
'decryptMemberPendingProfileKey: Member had missing addedByUserId'
);
let addedByUserId: AciString;
try {
addedByUserId = decryptAci(clientZkGroupCipher, member.addedByUserId);
} catch (error) {
log.warn(
`decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member addedByUserId. Dropping member.`,
Errors.toLogFormat(error)
);
return undefined;
}
// timestamp
const timestamp = normalizeTimestamp(member.timestamp);
if (!member.member) {
log.warn(
`decryptMemberPendingProfileKey/${logId}: Dropping pending member due to missing member details`
);
return undefined;
}
const { userId, profileKey } = member.member;
strictAssert(
Bytes.isEmpty(profileKey),
'decryptMemberPendingProfileKey: member has profileKey'
);
// userId
strictAssert(
Bytes.isNotEmpty(userId),
'decryptMemberPendingProfileKey: Member had missing member.userId'
);
let decryptedUserId: ServiceIdString;
try {
decryptedUserId = decryptServiceId(clientZkGroupCipher, userId);
} catch (error) {
log.warn(
`decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member userId. Dropping member.`,
Errors.toLogFormat(error)
);
return undefined;
}
// role
const role = dropNull(member.member.role);
strictAssert(
isValidRole(role),
`decryptMemberPendingProfileKey: Member had invalid role ${role}`
);
return {
addedByUserId,
timestamp,
member: {
userId: decryptedUserId,
role,
},
};
}
type DecryptedMemberPendingAdminApproval = {
userId: AciString;
profileKey?: Uint8Array;
timestamp: number;
};
function decryptMemberPendingAdminApproval(
clientZkGroupCipher: ClientZkGroupCipher,
member: Readonly<Proto.IMemberPendingAdminApproval>,
logId: string
): DecryptedMemberPendingAdminApproval | undefined {
// timestamp
const timestamp = normalizeTimestamp(member.timestamp);
const { userId, profileKey } = member;
// userId
strictAssert(
Bytes.isNotEmpty(userId),
'decryptMemberPendingAdminApproval: Missing userId'
);
let decryptedUserId: AciString;
try {
decryptedUserId = decryptAci(clientZkGroupCipher, userId);
} catch (error) {
log.warn(
`decryptMemberPendingAdminApproval/${logId}: Unable to decrypt pending member userId. Dropping member.`,
Errors.toLogFormat(error)
);
return undefined;
}
// profileKey
let decryptedProfileKey: Uint8Array | undefined;
if (Bytes.isNotEmpty(profileKey)) {
try {
decryptedProfileKey = decryptProfileKey(
clientZkGroupCipher,
profileKey,
decryptedUserId
);
} catch (error) {
log.warn(
`decryptMemberPendingAdminApproval/${logId}: Unable to decrypt profileKey. Dropping profileKey.`,
Errors.toLogFormat(error)
);
}
if (!isValidProfileKey(decryptedProfileKey)) {
log.warn(
`decryptMemberPendingAdminApproval/${logId}: Dropping profileKey, since it was invalid`
);
decryptedProfileKey = undefined;
}
}
return {
timestamp,
userId: decryptedUserId,
profileKey: decryptedProfileKey,
};
}
export function getMembershipList(
conversationId: string
): Array<{ aci: AciString; uuidCiphertext: Uint8Array }> {
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 aci = member.getCheckedAci('getMembershipList: member has no aci');
const uuidCiphertext = encryptServiceId(clientZkGroupCipher, aci);
return { aci, uuidCiphertext };
});
}