From cd122c72528aad94fea38b80ea4f2a3eaf3ca8f1 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 13 Jun 2024 09:36:12 +1000 Subject: [PATCH] fix: make pushChangesToGroupSwarm take an extraStoreRequest --- ts/components/MemberListItem.tsx | 4 + ts/models/conversation.ts | 14 +- ts/receiver/groupv2/handleGroupV2Message.ts | 3 - .../apis/snode_api/SnodeRequestTypes.ts | 77 ++++-- .../DeleteGroupHashesRequestFactory.ts | 35 +++ .../factories/StoreGroupRequestFactory.ts | 184 ++++++++++++++ .../conversations/ConversationController.ts | 3 +- ts/session/sending/MessageSender.ts | 17 +- .../jobs/GroupPendingRemovalsJob.ts | 9 +- .../utils/job_runners/jobs/GroupSyncJob.ts | 234 +++--------------- .../libsession/libsession_utils_contacts.ts | 1 - ts/shared/env_vars.ts | 4 +- ts/state/ducks/metaGroups.ts | 222 +++++++++-------- .../group_sync_job/GroupSyncJob_test.ts | 9 +- 14 files changed, 467 insertions(+), 349 deletions(-) create mode 100644 ts/session/apis/snode_api/factories/DeleteGroupHashesRequestFactory.ts create mode 100644 ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts diff --git a/ts/components/MemberListItem.tsx b/ts/components/MemberListItem.tsx index 44cfa2b97..f92440f5f 100644 --- a/ts/components/MemberListItem.tsx +++ b/ts/components/MemberListItem.tsx @@ -24,6 +24,7 @@ import { SessionButtonType, } from './basic/SessionButton'; import { SessionRadio } from './basic/SessionRadio'; +import { hasClosedGroupV2QAButtons } from '../shared/env_vars'; const AvatarContainer = styled.div` position: relative; @@ -215,6 +216,9 @@ const ResendPromoteButton = ({ pubkey: PubkeyType; groupPk: GroupPubkeyType; }) => { + if (!hasClosedGroupV2QAButtons()) { + return null; + } return ( { updatedExpirationSeconds: expireUpdate.expireTimer, }); + const extraStoreRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( + [v2groupMessage], + group + ); + await GroupSync.pushChangesToGroupSwarmIfNeeded({ groupPk: this.id, revokeSubRequest: null, unrevokeSubRequest: null, deleteAllMessagesSubRequest: null, - encryptedSupplementKeys: [], - }); - await GroupSync.storeGroupUpdateMessages({ - groupPk: this.id, - updateMessages: [v2groupMessage], + supplementalKeysSubRequest: [], + extraStoreRequests, }); + await GroupSync.queueNewJobIfNeeded(this.id); return true; } diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index 9bd838563..89274f7eb 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -355,7 +355,6 @@ async function handleGroupMemberLeftMessage({ memberLeft: author, }) ); - } async function handleGroupUpdateMemberLeftNotificationMessage({ @@ -491,7 +490,6 @@ async function handleGroupUpdateInviteResponseMessage({ } window.inboxStore.dispatch(groupInfoActions.inviteResponseReceived({ groupPk, member: author })); - } async function handleGroupUpdatePromoteMessage({ @@ -531,7 +529,6 @@ async function handleGroupUpdatePromoteMessage({ secret: groupKeypair.privateKey, }) ); - } async function handle1o1GroupUpdateMessage( diff --git a/ts/session/apis/snode_api/SnodeRequestTypes.ts b/ts/session/apis/snode_api/SnodeRequestTypes.ts index edf978261..caf06623e 100644 --- a/ts/session/apis/snode_api/SnodeRequestTypes.ts +++ b/ts/session/apis/snode_api/SnodeRequestTypes.ts @@ -25,6 +25,7 @@ import { WithSignature, WithTimestamp, } from './types'; +import { TTL_DEFAULT } from '../../constants'; type WithMaxSize = { max_size?: number }; export type WithShortenOrExtend = { shortenOrExtend: 'shorten' | 'extend' | '' }; @@ -818,32 +819,29 @@ export class StoreGroupMessageSubRequest extends SnodeAPISubRequest { } } -export class StoreGroupConfigSubRequest extends SnodeAPISubRequest { +abstract class StoreGroupConfigSubRequest< + T extends SnodeNamespacesGroupConfig | SnodeNamespaces.ClosedGroupRevokedRetrievableMessages, +> extends SnodeAPISubRequest { public method = 'store' as const; - public readonly namespace: - | SnodeNamespacesGroupConfig - | SnodeNamespaces.ClosedGroupRevokedRetrievableMessages; + public readonly namespace: T; public readonly destination: GroupPubkeyType; public readonly ttlMs: number; public readonly encryptedData: Uint8Array; + // this is mandatory for a group config store, if it is null, we throw public readonly secretKey: Uint8Array | null; - public readonly authData: Uint8Array | null; constructor( args: WithGroupPubkey & { - namespace: SnodeNamespacesGroupConfig | SnodeNamespaces.ClosedGroupRevokedRetrievableMessages; - ttlMs: number; + namespace: T; encryptedData: Uint8Array; - authData: Uint8Array | null; secretKey: Uint8Array | null; } ) { super(); this.namespace = args.namespace; this.destination = args.groupPk; - this.ttlMs = args.ttlMs; + this.ttlMs = TTL_DEFAULT.CONFIG_MESSAGE; this.encryptedData = args.encryptedData; - this.authData = args.authData; this.secretKey = args.secretKey; if (isEmpty(this.encryptedData)) { @@ -852,13 +850,8 @@ export class StoreGroupConfigSubRequest extends SnodeAPISubRequest { if (!PubKey.is03Pubkey(this.destination)) { throw new Error('StoreGroupConfigSubRequest: groupconfig namespace required a 03 pubkey'); } - if (isEmpty(this.secretKey) && isEmpty(this.authData)) { - throw new Error('StoreGroupConfigSubRequest needs either authData or secretKey to be set'); - } - if (SnodeNamespace.isGroupConfigNamespace(this.namespace) && isEmpty(this.secretKey)) { - throw new Error( - `StoreGroupConfigSubRequest: groupconfig namespace [${this.namespace}] requires an adminSecretKey` - ); + if (isEmpty(this.secretKey)) { + throw new Error('StoreGroupConfigSubRequest needs secretKey to be set'); } } @@ -872,7 +865,7 @@ export class StoreGroupConfigSubRequest extends SnodeAPISubRequest { const signDetails = await SnodeGroupSignature.getSnodeGroupSignature({ method: this.method, namespace: this.namespace, - group: { authData: this.authData, pubkeyHex: this.destination, secretKey: this.secretKey }, + group: { authData: null, pubkeyHex: this.destination, secretKey: this.secretKey }, }); if (!signDetails) { @@ -897,6 +890,36 @@ export class StoreGroupConfigSubRequest extends SnodeAPISubRequest { } } +export class StoreGroupInfoSubRequest extends StoreGroupConfigSubRequest { + constructor( + args: Omit[0], 'namespace'> + ) { + super({ ...args, namespace: SnodeNamespaces.ClosedGroupInfo }); + } +} +export class StoreGroupMembersSubRequest extends StoreGroupConfigSubRequest { + constructor( + args: Omit[0], 'namespace'> + ) { + super({ ...args, namespace: SnodeNamespaces.ClosedGroupMembers }); + } +} +export class StoreGroupKeysSubRequest extends StoreGroupConfigSubRequest { + constructor( + args: Omit[0], 'namespace'> + ) { + super({ ...args, namespace: SnodeNamespaces.ClosedGroupKeys }); + } +} + +export class StoreGroupRevokedRetrievableSubRequest extends StoreGroupConfigSubRequest { + constructor( + args: Omit[0], 'namespace'> + ) { + super({ ...args, namespace: SnodeNamespaces.ClosedGroupRevokedRetrievableMessages }); + } +} + export class StoreUserConfigSubRequest extends SnodeAPISubRequest { public method = 'store' as const; public readonly namespace: SnodeNamespacesUserConfig; @@ -1136,8 +1159,11 @@ export type RawSnodeSubRequests = | RetrieveLegacyClosedGroupSubRequest | RetrieveUserSubRequest | RetrieveGroupSubRequest - | StoreGroupConfigSubRequest + | StoreGroupInfoSubRequest + | StoreGroupMembersSubRequest + | StoreGroupKeysSubRequest | StoreGroupMessageSubRequest + | StoreGroupRevokedRetrievableSubRequest | StoreUserConfigSubRequest | SwarmForSubRequest | OnsResolveSubRequest @@ -1159,13 +1185,16 @@ export type BuiltSnodeSubRequests = | ReturnType | AwaitedReturn | AwaitedReturn - | AwaitedReturn + | AwaitedReturn + | AwaitedReturn + | AwaitedReturn | AwaitedReturn + | AwaitedReturn | AwaitedReturn - | ReturnType - | ReturnType - | ReturnType - | ReturnType + | AwaitedReturn + | AwaitedReturn + | AwaitedReturn + | AwaitedReturn | AwaitedReturn | AwaitedReturn | AwaitedReturn diff --git a/ts/session/apis/snode_api/factories/DeleteGroupHashesRequestFactory.ts b/ts/session/apis/snode_api/factories/DeleteGroupHashesRequestFactory.ts new file mode 100644 index 000000000..9c2b92165 --- /dev/null +++ b/ts/session/apis/snode_api/factories/DeleteGroupHashesRequestFactory.ts @@ -0,0 +1,35 @@ +import { UserGroupsGet } from 'libsession_util_nodejs'; +import { isEmpty } from 'lodash'; +import { ed25519Str } from '../../../utils/String'; +import { DeleteHashesFromGroupNodeSubRequest } from '../SnodeRequestTypes'; + +function makeGroupHashesToDeleteSubRequest({ + allOldHashes, + group, +}: { + group: Pick; + allOldHashes: Set; +}) { + const groupPk = group.pubkeyHex; + const allOldHashesArray = [...allOldHashes]; + if (allOldHashesArray.length) { + if (!group.secretKey || isEmpty(group.secretKey)) { + window.log.debug( + `makeGroupHashesToDeleteSubRequest: ${ed25519Str(groupPk)}: allOldHashesArray not empty but we do not have the secretKey` + ); + + throw new Error( + 'makeGroupHashesToDeleteSubRequest: allOldHashesArray not empty but we do not have the secretKey' + ); + } + + return new DeleteHashesFromGroupNodeSubRequest({ + messagesHashes: [...allOldHashes], + groupPk, + secretKey: group.secretKey, + }); + } + return null; +} + +export const DeleteGroupHashesFactory = { makeGroupHashesToDeleteSubRequest }; diff --git a/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts b/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts new file mode 100644 index 000000000..e8bd8ff79 --- /dev/null +++ b/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts @@ -0,0 +1,184 @@ +import { UserGroupsGet } from 'libsession_util_nodejs'; +import { compact, isEmpty } from 'lodash'; +import { SignalService } from '../../../../protobuf'; +import { MetaGroupWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; +import { GroupUpdateInfoChangeMessage } from '../../../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage'; +import { GroupUpdateMemberChangeMessage } from '../../../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateMemberChangeMessage'; +import { MessageSender } from '../../../sending'; +import { ed25519Str } from '../../../utils/String'; +import { PendingChangesForGroup } from '../../../utils/libsession/libsession_utils'; +import { + StoreGroupExtraData, + StoreGroupInfoSubRequest, + StoreGroupKeysSubRequest, + StoreGroupMembersSubRequest, + StoreGroupMessageSubRequest, +} from '../SnodeRequestTypes'; +import { SnodeNamespaces } from '../namespaces'; + +export type StoreMessageToSubRequestType = + | GroupUpdateMemberChangeMessage + | GroupUpdateInfoChangeMessage; + +async function makeGroupMessageSubRequest( + updateMessages: Array, + group: Pick +) { + const compactedMessages = compact(updateMessages); + if (isEmpty(compactedMessages)) { + return []; + } + const groupPk = compactedMessages[0].destination; + const allForSameDestination = compactedMessages.every(m => m.destination === groupPk); + if (!allForSameDestination) { + throw new Error('makeGroupMessageSubRequest: not all messages are for the same destination'); + } + + const messagesToEncrypt: Array = compactedMessages.map(updateMessage => { + const wrapped = MessageSender.wrapContentIntoEnvelope( + SignalService.Envelope.Type.SESSION_MESSAGE, + undefined, + updateMessage.createAtNetworkTimestamp, // message is signed with this timestmap + updateMessage.plainTextBuffer() + ); + + return { + namespace: SnodeNamespaces.ClosedGroupMessages, + pubkey: updateMessage.destination, + ttl: updateMessage.ttl(), + networkTimestamp: updateMessage.createAtNetworkTimestamp, + data: SignalService.Envelope.encode(wrapped).finish(), + dbMessageIdentifier: updateMessage.identifier, + }; + }); + + const encryptedContent = messagesToEncrypt.length + ? await MetaGroupWrapperActions.encryptMessages( + groupPk, + messagesToEncrypt.map(m => m.data) + ) + : []; + if (encryptedContent.length !== messagesToEncrypt.length) { + throw new Error( + 'makeGroupMessageSubRequest: MetaGroupWrapperActions.encryptMessages did not return the right count of items' + ); + } + + const updateMessagesEncrypted = messagesToEncrypt.map((requestDetails, index) => ({ + ...requestDetails, + data: encryptedContent[index], + })); + + const updateMessagesRequests = updateMessagesEncrypted.map(m => { + return new StoreGroupMessageSubRequest({ + encryptedData: m.data, + groupPk, + ttlMs: m.ttl, + dbMessageIdentifier: m.dbMessageIdentifier, + ...group, + createdAtNetworkTimestamp: m.networkTimestamp, + }); + }); + + return updateMessagesRequests; +} + +function makeStoreGroupKeysSubRequest({ + encryptedSupplementKeys, + group, +}: { + group: Pick; + encryptedSupplementKeys: Array; +}) { + const groupPk = group.pubkeyHex; + if (!encryptedSupplementKeys.length) { + return []; + } + + // supplementalKeys are already encrypted, but we still need the secretKey to sign the request + + if (!group.secretKey || isEmpty(group.secretKey)) { + window.log.debug( + `pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: keysEncryptedmessage not empty but we do not have the secretKey` + ); + + throw new Error( + 'pushChangesToGroupSwarmIfNeeded: keysEncryptedmessage not empty but we do not have the secretKey' + ); + } + return encryptedSupplementKeys.map(encryptedData => { + return new StoreGroupKeysSubRequest({ + encryptedData, + groupPk, + secretKey: group.secretKey, + }); + }); +} + +function makeStoreGroupConfigSubRequest({ + group, + pendingConfigData, +}: { + group: Pick; + pendingConfigData: Array; +}) { + if (!pendingConfigData.length) { + return []; + } + const groupPk = group.pubkeyHex; + + if (!group.secretKey || isEmpty(group.secretKey)) { + window.log.debug( + `pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: pendingConfigMsgs not empty but we do not have the secretKey` + ); + + throw new Error( + 'pushChangesToGroupSwarmIfNeeded: pendingConfigMsgs not empty but we do not have the secretKey' + ); + } + + const groupInfoSubRequests = compact( + pendingConfigData.map(m => + m.namespace === SnodeNamespaces.ClosedGroupInfo + ? new StoreGroupInfoSubRequest({ + encryptedData: m.ciphertext, + groupPk, + secretKey: group.secretKey, + }) + : null + ) + ); + + const groupMembersSubRequests = compact( + pendingConfigData.map(m => + m.namespace === SnodeNamespaces.ClosedGroupMembers + ? new StoreGroupMembersSubRequest({ + encryptedData: m.ciphertext, + groupPk, + secretKey: group.secretKey, + }) + : null + ) + ); + + const groupKeysSubRequests = compact( + pendingConfigData.map(m => + m.namespace === SnodeNamespaces.ClosedGroupKeys + ? new StoreGroupKeysSubRequest({ + encryptedData: m.ciphertext, + groupPk, + secretKey: group.secretKey, + }) + : null + ) + ); + + // we want to store first the keys (as the info and members might already be encrypted with them) + return [...groupKeysSubRequests, ...groupInfoSubRequests, ...groupMembersSubRequests]; +} + +export const StoreGroupRequestFactory = { + makeGroupMessageSubRequest, + makeStoreGroupConfigSubRequest, + makeStoreGroupKeysSubRequest, +}; diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 439f22ee4..198fa6fb4 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -329,8 +329,9 @@ class ConvoController { groupPk, revokeSubRequest: null, unrevokeSubRequest: null, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], deleteAllMessagesSubRequest, + extraStoreRequests: [], }); if (lastPushResult !== RunJobResult.Success) { throw new Error(`Failed to destroyGroupDetails for pk ${ed25519Str(groupPk)}`); diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index 8a6daa964..768747654 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -30,8 +30,11 @@ import { RetrieveGroupSubRequest, RetrieveLegacyClosedGroupSubRequest, RetrieveUserSubRequest, - StoreGroupConfigSubRequest, + StoreGroupInfoSubRequest, + StoreGroupKeysSubRequest, + StoreGroupMembersSubRequest, StoreGroupMessageSubRequest, + StoreGroupRevokedRetrievableSubRequest, StoreLegacyGroupMessageSubRequest, StoreUserConfigSubRequest, StoreUserMessageSubRequest, @@ -89,7 +92,12 @@ type StoreRequest05 = | StoreUserConfigSubRequest | StoreUserMessageSubRequest | StoreLegacyGroupMessageSubRequest; -type StoreRequest03 = StoreGroupConfigSubRequest | StoreGroupMessageSubRequest; +type StoreRequest03 = + | StoreGroupInfoSubRequest + | StoreGroupMembersSubRequest + | StoreGroupKeysSubRequest + | StoreGroupRevokedRetrievableSubRequest + | StoreGroupMessageSubRequest; type PubkeyToRequestType = T extends PubkeyType ? StoreRequest05 @@ -366,7 +374,10 @@ async function signSubRequests( p instanceof DeleteHashesFromUserNodeSubRequest || p instanceof DeleteHashesFromGroupNodeSubRequest || p instanceof DeleteAllFromUserNodeSubRequest || - p instanceof StoreGroupConfigSubRequest || + p instanceof StoreGroupInfoSubRequest || + p instanceof StoreGroupMembersSubRequest || + p instanceof StoreGroupKeysSubRequest || + p instanceof StoreGroupRevokedRetrievableSubRequest || p instanceof StoreGroupMessageSubRequest || p instanceof StoreLegacyGroupMessageSubRequest || p instanceof StoreUserConfigSubRequest || diff --git a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts index e2aad7863..9fdf9f831 100644 --- a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts @@ -13,11 +13,10 @@ import { MultiEncryptWrapperActions, UserGroupsWrapperActions, } from '../../../../webworker/workers/browser/libsession_worker_interface'; +import { StoreGroupRevokedRetrievableSubRequest } from '../../../apis/snode_api/SnodeRequestTypes'; import { GetNetworkTime } from '../../../apis/snode_api/getNetworkTime'; -import { SnodeNamespaces } from '../../../apis/snode_api/namespaces'; import { RevokeChanges, SnodeAPIRevoke } from '../../../apis/snode_api/revokeSubaccount'; import { WithSecretKey } from '../../../apis/snode_api/types'; -import { TTL_DEFAULT } from '../../../constants'; import { concatUInt8Array } from '../../../crypto'; import { MessageSender } from '../../../sending'; import { fromHexToArray } from '../../String'; @@ -29,7 +28,6 @@ import { RunJobResult, } from '../PersistedJob'; import { GroupSync } from './GroupSyncJob'; -import { StoreGroupConfigSubRequest } from '../../../apis/snode_api/SnodeRequestTypes'; export type WithAddWithoutHistoryMembers = { withoutHistory: Array }; export type WithAddWithHistoryMembers = { withHistory: Array }; @@ -154,13 +152,10 @@ class GroupPendingRemovalsJob extends PersistedJob; -}) { - if (!updateMessages.length) { - return true; - } - - const group = await UserGroupsWrapperActions.getGroup(groupPk); - if (!group) { - window.log.warn( - `storeGroupUpdateMessages for ${ed25519Str(groupPk)}: no group found in wrapper` - ); - return false; - } - - const updateMessagesToEncrypt: Array = updateMessages.map(updateMessage => { - const wrapped = MessageSender.wrapContentIntoEnvelope( - SignalService.Envelope.Type.SESSION_MESSAGE, - undefined, - updateMessage.createAtNetworkTimestamp, // message is signed with this timestmap - updateMessage.plainTextBuffer() - ); - - return { - namespace: SnodeNamespaces.ClosedGroupMessages, - pubkey: groupPk, - ttl: updateMessage.ttl(), - networkTimestamp: updateMessage.createAtNetworkTimestamp, - data: SignalService.Envelope.encode(wrapped).finish(), - dbMessageIdentifier: updateMessage.identifier, - }; - }); - - const encryptedUpdate = updateMessagesToEncrypt - ? await MetaGroupWrapperActions.encryptMessages( - groupPk, - updateMessagesToEncrypt.map(m => m.data) - ) - : []; - - const updateMessagesEncrypted = updateMessagesToEncrypt.map((requestDetails, index) => ({ - ...requestDetails, - data: encryptedUpdate[index], - })); - - const updateMessagesRequests = updateMessagesEncrypted.map(m => { - return new StoreGroupMessageSubRequest({ - encryptedData: m.data, - groupPk, - ttlMs: m.ttl, - dbMessageIdentifier: m.dbMessageIdentifier, - ...group, - createdAtNetworkTimestamp: m.networkTimestamp, - }); - }); - - const result = await MessageSender.sendEncryptedDataToSnode({ - storeRequests: [...updateMessagesRequests], - destination: groupPk, - deleteHashesSubRequest: null, - revokeSubRequest: null, - unrevokeSubRequest: null, - deleteAllMessagesSubRequest: null, - }); - - const expectedReplyLength = updateMessagesRequests.length; // each of those messages are sent as a subrequest - - // we do a sequence call here. If we do not have the right expected number of results, consider it a failure - if (!isArray(result) || result.length !== expectedReplyLength) { - window.log.info( - `GroupSyncJob: unexpected result length: expected ${expectedReplyLength} but got ${result?.length}` - ); - - // this might be a 421 error (already handled) so let's retry this request a little bit later - return false; - } - return true; -} - async function pushChangesToGroupSwarmIfNeeded({ revokeSubRequest, unrevokeSubRequest, groupPk, - encryptedSupplementKeys, + supplementalKeysSubRequest, deleteAllMessagesSubRequest, + extraStoreRequests, }: WithGroupPubkey & WithRevokeSubRequest & { - encryptedSupplementKeys: Array; + supplementalKeysSubRequest: Array; deleteAllMessagesSubRequest?: DeleteAllFromGroupMsgNodeSubRequest | null; + extraStoreRequests: Array; }): Promise { // save the dumps to DB even before trying to push them, so at least we have an up to date dumps in the DB in case of crash, no network etc await LibSessionUtil.saveDumpsToDb(groupPk); @@ -182,10 +97,11 @@ async function pushChangesToGroupSwarmIfNeeded({ // is updated we want to try and run immediately so don't schedule another run in this case) if ( isEmpty(pendingConfigData) && - !encryptedSupplementKeys.length && - !revokeSubRequest && - !unrevokeSubRequest && - !deleteAllMessagesSubRequest + isEmpty(supplementalKeysSubRequest) && + isEmpty(revokeSubRequest) && + isEmpty(unrevokeSubRequest) && + isEmpty(deleteAllMessagesSubRequest) && + isEmpty(extraStoreRequests) ) { window.log.debug(`pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: nothing to push`); return RunJobResult.Success; @@ -205,109 +121,20 @@ async function pushChangesToGroupSwarmIfNeeded({ ); } - const networkTimestamp = GetNetworkTime.now(); - - const pendingConfigMsgs = pendingConfigData.map(item => { - return { - namespace: item.namespace, - pubkey: groupPk, - networkTimestamp, - ttl: TTL_DEFAULT.CONFIG_MESSAGE, - data: item.ciphertext, - }; + const pendingConfigRequests = StoreGroupRequestFactory.makeStoreGroupConfigSubRequest({ + group, + pendingConfigData, }); - // supplementKeys are already encrypted by libsession - const keysEncryptedMessages: Array = encryptedSupplementKeys.map(key => ({ - namespace: SnodeNamespaces.ClosedGroupKeys, - pubkey: groupPk, - ttl: TTL_DEFAULT.CONFIG_MESSAGE, - networkTimestamp, - data: key, - dbMessageIdentifier: null, - })); - - let pendingConfigRequests: Array = []; - let keysEncryptedRequests: Array = []; - - if (pendingConfigMsgs.length) { - if (!group.secretKey || isEmpty(group.secretKey)) { - window.log.debug( - `pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: pendingConfigMsgs not empty but we do not have the secretKey` - ); - - throw new Error( - 'pushChangesToGroupSwarmIfNeeded: pendingConfigMsgs not empty but we do not have the secretKey' - ); - } - - pendingConfigRequests = pendingConfigMsgs.map(m => { - return new StoreGroupConfigSubRequest({ - encryptedData: m.data, - groupPk, - namespace: m.namespace, - ttlMs: m.ttl, - secretKey: group.secretKey, - authData: null, - }); - }); - } - - if (keysEncryptedMessages.length) { - // supplementalKeys are already encrypted, but we still need the secretKey to sign the request - - if (!group.secretKey || isEmpty(group.secretKey)) { - window.log.debug( - `pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: keysEncryptedmessage not empty but we do not have the secretKey` - ); - - throw new Error( - 'pushChangesToGroupSwarmIfNeeded: keysEncryptedmessage not empty but we do not have the secretKey' - ); - } - keysEncryptedRequests = keysEncryptedMessages.map(m => { - return new StoreGroupConfigSubRequest({ - encryptedData: m.data, - groupPk, - namespace: SnodeNamespaces.ClosedGroupKeys, - ttlMs: m.ttl, - secretKey: group.secretKey, - authData: null, - }); - }); - } - - let deleteHashesSubRequest: DeleteHashesFromGroupNodeSubRequest | null = null; - const allOldHashesArray = [...allOldHashes]; - if (allOldHashesArray.length) { - if (!group.secretKey || isEmpty(group.secretKey)) { - window.log.debug( - `pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: allOldHashesArray not empty but we do not have the secretKey` - ); - - throw new Error( - 'pushChangesToGroupSwarmIfNeeded: allOldHashesArray not empty but we do not have the secretKey' - ); - } - - deleteHashesSubRequest = new DeleteHashesFromGroupNodeSubRequest({ - messagesHashes: [...allOldHashes], - groupPk, - secretKey: group.secretKey, - }); - } - - if ( - revokeSubRequest?.revokeTokenHex.length === 0 || - unrevokeSubRequest?.revokeTokenHex.length === 0 - ) { - throw new Error( - 'revokeSubRequest and unrevoke request must be null when not doing token change' - ); - } + const deleteHashesSubRequest = DeleteGroupHashesFactory.makeGroupHashesToDeleteSubRequest({ + group, + allOldHashes, + }); const result = await MessageSender.sendEncryptedDataToSnode({ - storeRequests: [...pendingConfigRequests, ...keysEncryptedRequests], + // Note: this is on purpose that supplementalKeysSubRequest is before pendingConfigRequests + // as this is to avoid a race condition where a device polls while we are posting the configs (already encrypted with the new keys) + storeRequests: [...supplementalKeysSubRequest, ...pendingConfigRequests, ...extraStoreRequests], destination: groupPk, deleteHashesSubRequest, revokeSubRequest, @@ -316,12 +143,13 @@ async function pushChangesToGroupSwarmIfNeeded({ }); const expectedReplyLength = - pendingConfigRequests.length + // each of those messages are sent as a subrequest - keysEncryptedRequests.length + // each of those messages are sent as a subrequest + pendingConfigRequests.length + // each of those are sent as a subrequest + supplementalKeysSubRequest.length + // each of those are sent as a subrequest (allOldHashes.size ? 1 : 0) + // we are sending all hashes changes as a single subrequest (revokeSubRequest ? 1 : 0) + // we are sending all revoke updates as a single subrequest (unrevokeSubRequest ? 1 : 0) + // we are sending all revoke updates as a single subrequest - (deleteAllMessagesSubRequest ? 1 : 0); // a delete_all sub request is a single subrequest + (deleteAllMessagesSubRequest ? 1 : 0) + // a delete_all sub request is a single subrequest + (extraStoreRequests ? 1 : 0); // each of those are sent as a subrequest // we do a sequence call here. If we do not have the right expected number of results, consider it a failure if (!isArray(result) || result.length !== expectedReplyLength) { @@ -341,9 +169,9 @@ async function pushChangesToGroupSwarmIfNeeded({ if (isEmpty(changes)) { return RunJobResult.RetryJobIfPossible; } + // Now that we have the successful changes, we need to mark them as pushed and // generate any config dumps which need to be stored - await confirmPushedAndDump(changes, groupPk); return RunJobResult.Success; } @@ -393,7 +221,8 @@ class GroupSyncJob extends PersistedJob { groupPk: thisJobDestination, revokeSubRequest: null, unrevokeSubRequest: null, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], + extraStoreRequests: [], }); // eslint-disable-next-line no-useless-catch @@ -473,7 +302,6 @@ async function queueNewJobIfNeeded(groupPk: GroupPubkeyType) { export const GroupSync = { GroupSyncJob, pushChangesToGroupSwarmIfNeeded, - storeGroupUpdateMessages, queueNewJobIfNeeded: (groupPk: GroupPubkeyType) => allowOnlyOneAtATime(`GroupSyncJob-oneAtAtTime-${groupPk}`, () => queueNewJobIfNeeded(groupPk)), }; diff --git a/ts/session/utils/libsession/libsession_utils_contacts.ts b/ts/session/utils/libsession/libsession_utils_contacts.ts index aed810baf..f1301f269 100644 --- a/ts/session/utils/libsession/libsession_utils_contacts.ts +++ b/ts/session/utils/libsession/libsession_utils_contacts.ts @@ -70,7 +70,6 @@ async function insertContactFromDBIntoWrapperAndRefresh( const expirationMode = foundConvo.get('expirationMode') || undefined; const expireTimer = foundConvo.get('expireTimer') || 0; - const wrapperContact = getContactInfoFromDBValues({ id, dbApproved, diff --git a/ts/shared/env_vars.ts b/ts/shared/env_vars.ts index 8cfb2c64b..0851fa9c7 100644 --- a/ts/shared/env_vars.ts +++ b/ts/shared/env_vars.ts @@ -27,5 +27,5 @@ export function isTestIntegration() { } export function hasClosedGroupV2QAButtons() { - return !!window.sessionFeatureFlags.useClosedGroupV2QAButtons -} \ No newline at end of file + return !!window.sessionFeatureFlags.useClosedGroupV2QAButtons; +} diff --git a/ts/state/ducks/metaGroups.ts b/ts/state/ducks/metaGroups.ts index 177cd7813..c736024aa 100644 --- a/ts/state/ducks/metaGroups.ts +++ b/ts/state/ducks/metaGroups.ts @@ -17,6 +17,7 @@ import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { HexString } from '../../node/hexStrings'; import { SignalService } from '../../protobuf'; import { getSwarmPollingInstance } from '../../session/apis/snode_api'; +import { StoreGroupRequestFactory } from '../../session/apis/snode_api/factories/StoreGroupRequestFactory'; import { GetNetworkTime } from '../../session/apis/snode_api/getNetworkTime'; import { ConvoHub } from '../../session/conversations'; import { getSodiumRenderer } from '../../session/crypto'; @@ -51,6 +52,7 @@ import { import { StateType } from '../reducer'; import { openConversationWithMessages } from './conversations'; import { resetLeftOverlayMode } from './section'; +import { ed25519Str } from '../../session/utils/String'; type WithFromMemberLeftMessage = { fromMemberLeftMessage: boolean }; // there are some changes we want to skip when doing changes triggered from a memberLeft message. export type GroupState = { @@ -177,19 +179,7 @@ const initNewGroupInWrapper = createAsyncThunk( const convo = await ConvoHub.use().getOrCreateAndWait(groupPk, ConversationTypeEnum.GROUPV2); await convo.setIsApproved(true, false); await convo.commit(); // commit here too, as the poll needs it to be approved - - const result = await GroupSync.pushChangesToGroupSwarmIfNeeded({ - groupPk, - revokeSubRequest: null, - unrevokeSubRequest: null, - encryptedSupplementKeys: [], - deleteAllMessagesSubRequest: null, - }); - if (result !== RunJobResult.Success) { - window.log.warn('GroupSync.pushChangesToGroupSwarmIfNeeded during create failed'); - throw new Error('failed to pushChangesToGroupSwarmIfNeeded'); - } - + let groupMemberChange: GroupUpdateMemberChangeMessage | null = null; // push one group change message were initial members are added to the group if (membersFromWrapper.length) { const membersHex = uniq(membersFromWrapper.map(m => m.pubkeyHex)); @@ -202,7 +192,7 @@ const initNewGroupInWrapper = createAsyncThunk( convo, markAlreadySent: false, // the store below will mark the message as sent with dbMsgIdentifier }); - const groupChange = await getWithoutHistoryControlMessage({ + groupMemberChange = await getWithoutHistoryControlMessage({ adminSecretKey: groupSecretKey, convo, groupPk, @@ -210,12 +200,24 @@ const initNewGroupInWrapper = createAsyncThunk( createAtNetworkTimestamp: sentAt, dbMsgIdentifier: msgModel.id, }); - if (groupChange) { - await GroupSync.storeGroupUpdateMessages({ - groupPk, - updateMessages: [groupChange], - }); - } + } + + const extraStoreRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( + [groupMemberChange], + { authData: null, secretKey: newGroup.secretKey } + ); + + const result = await GroupSync.pushChangesToGroupSwarmIfNeeded({ + groupPk, + revokeSubRequest: null, + unrevokeSubRequest: null, + supplementalKeysSubRequest: [], + deleteAllMessagesSubRequest: null, + extraStoreRequests, + }); + if (result !== RunJobResult.Success) { + window.log.warn('GroupSync.pushChangesToGroupSwarmIfNeeded during create failed'); + throw new Error('failed to pushChangesToGroupSwarmIfNeeded'); } await convo.commit(); @@ -647,18 +649,24 @@ async function handleMemberAddedFromUI({ groupPk, }); // first, get the unrevoke requests for people who are added - const revokeUnrevokeParams = await GroupPendingRemovals.getPendingRevokeParams({ - groupPk, - withHistory, - withoutHistory, - removed: [], - secretKey: group.secretKey, - }); + const { revokeSubRequest, unrevokeSubRequest } = + await GroupPendingRemovals.getPendingRevokeParams({ + groupPk, + withHistory, + withoutHistory, + removed: [], + secretKey: group.secretKey, + }); // then, handle the addition with history of messages by generating supplement keys. // this adds them to the members wrapper etc const encryptedSupplementKeys = await handleWithHistoryMembers({ groupPk, withHistory }); + const supplementalKeysSubRequest = StoreGroupRequestFactory.makeStoreGroupKeysSubRequest({ + group, + encryptedSupplementKeys, + }); + // then handle the addition without history of messages (full rotation of keys). // this adds them to the members wrapper etc await handleWithoutHistoryMembers({ groupPk, withoutHistory }); @@ -666,27 +674,6 @@ async function handleMemberAddedFromUI({ await LibSessionUtil.saveDumpsToDb(groupPk); - // push new members & key supplement in a single batch call - const sequenceResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ - groupPk, - encryptedSupplementKeys, - ...revokeUnrevokeParams, - deleteAllMessagesSubRequest: null, - }); - if (sequenceResult !== RunJobResult.Success) { - throw new Error( - 'handleMemberAddedFromUIOrNot: pushChangesToGroupSwarmIfNeeded did not return success' - ); - } - - // schedule send invite details, auth signature, etc. to the new users - await scheduleGroupInviteJobs(groupPk, withHistory, withoutHistory); - await LibSessionUtil.saveDumpsToDb(groupPk); - - convo.set({ - active_at: createAtNetworkTimestamp, - }); - const expireDetails = DisappearingMessages.getExpireDetailsForOutgoingMesssage( convo, createAtNetworkTimestamp @@ -698,7 +685,6 @@ async function handleMemberAddedFromUI({ expireUpdate: expireDetails, markAlreadySent: false, // the store below will mark the message as sent with dbMsgIdentifier }; - const updateMessagesToPush: Array = []; if (withHistory.length) { const msgModel = await ClosedGroup.addUpdateMessage({ @@ -735,8 +721,35 @@ async function handleMemberAddedFromUI({ } } + const extraStoreRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( + updateMessagesToPush, + group + ); + + // push new members & key supplement in a single batch call + const sequenceResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ + groupPk, + supplementalKeysSubRequest, + revokeSubRequest, + unrevokeSubRequest, + deleteAllMessagesSubRequest: null, + extraStoreRequests, + }); + if (sequenceResult !== RunJobResult.Success) { + throw new Error( + 'handleMemberAddedFromUIOrNot: pushChangesToGroupSwarmIfNeeded did not return success' + ); + } + + // schedule send invite details, auth signature, etc. to the new users + await scheduleGroupInviteJobs(groupPk, withHistory, withoutHistory); + await LibSessionUtil.saveDumpsToDb(groupPk); + + convo.set({ + active_at: createAtNetworkTimestamp, + }); + await convo.commit(); - await GroupSync.storeGroupUpdateMessages({ groupPk, updateMessages: updateMessagesToPush }); } /** @@ -782,13 +795,51 @@ async function handleMemberRemovedFromUI({ const createAtNetworkTimestamp = GetNetworkTime.now(); await LibSessionUtil.saveDumpsToDb(groupPk); + const expiringDetails = DisappearingMessages.getExpireDetailsForOutgoingMesssage( + convo, + createAtNetworkTimestamp + ); + let removedControlMessage: GroupUpdateMemberChangeMessage | null = null; + if (removed.length && !fromMemberLeftMessage) { + const msgModel = await ClosedGroup.addUpdateMessage({ + diff: { type: 'kicked', kicked: removed }, + convo, + sender: us, + sentAt: createAtNetworkTimestamp, + expireUpdate: { + expirationTimer: expiringDetails.expireTimer, + expirationType: expiringDetails.expirationType, + messageExpirationFromRetrieve: + expiringDetails.expireTimer > 0 + ? createAtNetworkTimestamp + expiringDetails.expireTimer + : null, + }, + markAlreadySent: false, // the store below will mark the message as sent with dbMsgIdentifier + }); + removedControlMessage = await getRemovedControlMessage({ + adminSecretKey: group.secretKey, + convo, + groupPk, + removed, + createAtNetworkTimestamp, + fromMemberLeftMessage, + dbMsgIdentifier: msgModel.id, + }); + } + + const extraStoreRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( + [removedControlMessage], + group + ); + // revoked pubkeys, update messages, and libsession groups config in a single batch call const sequenceResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ groupPk, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], revokeSubRequest: null, unrevokeSubRequest: null, deleteAllMessagesSubRequest: null, + extraStoreRequests, }); if (sequenceResult !== RunJobResult.Success) { throw new Error( @@ -801,49 +852,7 @@ async function handleMemberRemovedFromUI({ convo.set({ active_at: createAtNetworkTimestamp, }); - - const expiringDetails = DisappearingMessages.getExpireDetailsForOutgoingMesssage( - convo, - createAtNetworkTimestamp - ); - - const shared = { - convo, - sender: us, - sentAt: createAtNetworkTimestamp, - expireUpdate: { - expirationTimer: expiringDetails.expireTimer, - expirationType: expiringDetails.expirationType, - messageExpirationFromRetrieve: - expiringDetails.expireTimer > 0 - ? createAtNetworkTimestamp + expiringDetails.expireTimer - : null, - }, - }; await convo.commit(); - - if (removed.length && !fromMemberLeftMessage) { - const msgModel = await ClosedGroup.addUpdateMessage({ - diff: { type: 'kicked', kicked: removed }, - ...shared, - markAlreadySent: false, // the store below will mark the message as sent with dbMsgIdentifier - }); - const removedControlMessage = await getRemovedControlMessage({ - adminSecretKey: group.secretKey, - convo, - groupPk, - removed, - createAtNetworkTimestamp, - fromMemberLeftMessage, - dbMsgIdentifier: msgModel.id, - }); - if (removedControlMessage) { - await GroupSync.storeGroupUpdateMessages({ - groupPk, - updateMessages: [removedControlMessage], - }); - } - } } async function handleNameChangeFromUI({ @@ -901,12 +910,18 @@ async function handleNameChangeFromUI({ ...DisappearingMessages.getExpireDetailsForOutgoingMesssage(convo, createAtNetworkTimestamp), }); + const extraStoreRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( + [nameChangeMsg], + group + ); + const batchResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ groupPk, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], revokeSubRequest: null, unrevokeSubRequest: null, deleteAllMessagesSubRequest: null, + extraStoreRequests, }); if (batchResult !== RunJobResult.Success) { @@ -916,7 +931,6 @@ async function handleNameChangeFromUI({ } await UserSync.queueNewJobIfNeeded(); - await GroupSync.storeGroupUpdateMessages({ groupPk, updateMessages: [nameChangeMsg] }); convo.set({ active_at: createAtNetworkTimestamp, @@ -1018,10 +1032,24 @@ const triggerFakeAvatarUpdate = createAsyncThunk( secretKey: group.secretKey, sodium: await getSodiumRenderer(), }); - await GroupSync.storeGroupUpdateMessages({ + + const extraStoreRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( + [updateMsg], + group + ); + + const batchResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ groupPk, - updateMessages: [updateMsg], + supplementalKeysSubRequest: [], + revokeSubRequest: null, + unrevokeSubRequest: null, + deleteAllMessagesSubRequest: null, + extraStoreRequests, }); + if (!batchResult) { + window.log.warn(`failed to send avatarChange message for group ${ed25519Str(groupPk)}`); + throw new Error('failed to send avatarChange message'); + } } ); diff --git a/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts b/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts index 860563299..b0dd18a3c 100644 --- a/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts +++ b/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts @@ -277,7 +277,8 @@ describe('GroupSyncJob pushChangesToGroupSwarmIfNeeded', () => { groupPk, revokeSubRequest: null, unrevokeSubRequest: null, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], + extraStoreRequests: [], }); expect(result).to.be.eq(RunJobResult.Success); expect(sendStub.callCount).to.be.eq(0); @@ -302,7 +303,8 @@ describe('GroupSyncJob pushChangesToGroupSwarmIfNeeded', () => { groupPk, revokeSubRequest: null, unrevokeSubRequest: null, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], + extraStoreRequests: [], }); sendStub.resolves(undefined); @@ -376,7 +378,8 @@ describe('GroupSyncJob pushChangesToGroupSwarmIfNeeded', () => { groupPk, revokeSubRequest: null, unrevokeSubRequest: null, - encryptedSupplementKeys: [], + supplementalKeysSubRequest: [], + extraStoreRequests: [], }); expect(sendStub.callCount).to.be.eq(1);