From d7608c42b67f4f2315364f2bc89cc34e7a014b54 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 24 Oct 2023 13:53:28 +1100 Subject: [PATCH] feat: add building and sending of invite messages --- ts/session/crypto/group/groupSignature.ts | 51 +++++++++++++++++++ .../GroupUpdateDeleteMemberContentMessage.ts | 13 +++++ .../to_user/GroupUpdateDeleteMessage.ts | 8 +++ .../to_user/GroupUpdateInviteMessage.ts | 25 ++++----- ts/session/messages/outgoing/preconditions.ts | 37 ++++++++++++++ ts/state/ducks/groups.ts | 37 ++++++++++++-- .../libsession_wrapper_metagroup_test.ts | 7 +-- .../browser/libsession_worker_interface.ts | 23 ++++++--- 8 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 ts/session/crypto/group/groupSignature.ts create mode 100644 ts/session/messages/outgoing/preconditions.ts diff --git a/ts/session/crypto/group/groupSignature.ts b/ts/session/crypto/group/groupSignature.ts new file mode 100644 index 000000000..76c0d3502 --- /dev/null +++ b/ts/session/crypto/group/groupSignature.ts @@ -0,0 +1,51 @@ +import { GroupMemberGet, GroupPubkeyType, Uint8ArrayLen64 } from 'libsession_util_nodejs'; +import { compact } from 'lodash'; +import { MetaGroupWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; +import { GetNetworkTime } from '../../apis/snode_api/getNetworkTime'; +import { GroupUpdateInviteMessage } from '../../messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateInviteMessage'; +import { UserUtils } from '../../utils'; +import { getSodiumRenderer } from '../MessageEncrypter'; + +export async function getGroupInvitesMessages({ + groupName, + membersFromWrapper, + secretKey, + groupPk, +}: { + membersFromWrapper: Array; + groupName: string; + secretKey: Uint8ArrayLen64; // len 64 + groupPk: GroupPubkeyType; +}) { + const sodium = await getSodiumRenderer(); + const timestamp = GetNetworkTime.getNowWithNetworkOffset(); + + const inviteDetails = compact( + await Promise.all( + membersFromWrapper.map(async ({ pubkeyHex: member }) => { + if (UserUtils.isUsFromCache(member)) { + return null; + } + const tosign = `INVITE${member}${timestamp}`; + + // Note: as the signature is built with the timestamp here, we cannot override the timestamp later on the sending pipeline + const adminSignature = sodium.crypto_sign_detached(tosign, secretKey); + console.info(`before makeSwarmSubAccount ${groupPk}:${member}`); + const memberAuthData = await MetaGroupWrapperActions.makeSwarmSubAccount(groupPk, member); + debugger; + console.info(`after makeSwarmSubAccount ${groupPk}:${member}`); + + const invite = new GroupUpdateInviteMessage({ + groupName, + groupPk, + timestamp, + adminSignature, + memberAuthData, + }); + + return { member, invite }; + }) + ) + ); + return inviteDetails; +} diff --git a/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts b/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts index 33a5e5a55..84012654c 100644 --- a/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts @@ -1,6 +1,7 @@ import { PubkeyType } from 'libsession_util_nodejs'; import { isEmpty } from 'lodash'; import { SignalService } from '../../../../../../protobuf'; +import { Preconditions } from '../../../preconditions'; import { GroupUpdateMessage, GroupUpdateMessageParams } from '../GroupUpdateMessage'; type Params = GroupUpdateMessageParams & { @@ -23,6 +24,18 @@ export class GroupUpdateDeleteMemberContentMessage extends GroupUpdateMessage { if (isEmpty(this.memberSessionIds)) { throw new Error('GroupUpdateDeleteMemberContentMessage needs members in list'); } + Preconditions.checkUin8tArrayOrThrow({ + data: this.adminSignature, + expectedLength: 64, + varName: 'adminSignature', + context: this.constructor.toString(), + }); + + Preconditions.checkArrayHaveOnly05Pubkeys({ + arr: this.memberSessionIds, + context: this.constructor.toString(), + varName: 'memberSessionIds', + }); } public dataProto(): SignalService.DataMessage { diff --git a/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts b/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts index 328b41412..636229263 100644 --- a/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts @@ -1,4 +1,5 @@ import { SignalService } from '../../../../../../protobuf'; +import { Preconditions } from '../../../preconditions'; import { GroupUpdateMessage, GroupUpdateMessageParams } from '../GroupUpdateMessage'; interface Params extends GroupUpdateMessageParams { @@ -15,6 +16,13 @@ export class GroupUpdateDeleteMessage extends GroupUpdateMessage { super(params); this.adminSignature = params.adminSignature; + + Preconditions.checkUin8tArrayOrThrow({ + data: this.adminSignature, + expectedLength: 64, + varName: 'adminSignature', + context: this.constructor.toString(), + }); } public dataProto(): SignalService.DataMessage { diff --git a/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateInviteMessage.ts b/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateInviteMessage.ts index 380203759..93c253d04 100644 --- a/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateInviteMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateInviteMessage.ts @@ -25,18 +25,19 @@ export class GroupUpdateInviteMessage extends GroupUpdateMessage { this.groupName = groupName; // not sure if getting an invite with an empty group name should make us drop an incoming group invite (and the keys associated to it too) this.adminSignature = adminSignature; this.memberAuthData = memberAuthData; - Preconditions.checkUin8tArrayOrThrow( - memberAuthData, - 100, - 'memberAuthData', - 'GroupUpdateInviteMessage' - ); - Preconditions.checkUin8tArrayOrThrow( - adminSignature, - 32, - 'adminSignature', - 'GroupUpdateInviteMessage' - ); + + Preconditions.checkUin8tArrayOrThrow({ + data: adminSignature, + expectedLength: 64, + varName: 'adminSignature', + context: this.constructor.toString(), + }); + Preconditions.checkUin8tArrayOrThrow({ + data: memberAuthData, + expectedLength: 100, + varName: 'memberAuthData', + context: this.constructor.toString(), + }); } public dataProto(): SignalService.DataMessage { diff --git a/ts/session/messages/outgoing/preconditions.ts b/ts/session/messages/outgoing/preconditions.ts new file mode 100644 index 000000000..8a29e421f --- /dev/null +++ b/ts/session/messages/outgoing/preconditions.ts @@ -0,0 +1,37 @@ +import { isEmpty } from 'lodash'; +import { PubKey } from '../../types'; +import { PreConditionFailed } from '../../utils/errors'; + +function checkUin8tArrayOrThrow({ + context, + data, + expectedLength, + varName, +}: { + data: Uint8Array; + expectedLength: number; + varName: string; + context: string; +}) { + if (isEmpty(data) || data.length !== expectedLength) { + throw new PreConditionFailed( + `${varName} length should be ${expectedLength} for ctx:"${context}"` + ); + } +} + +function checkArrayHaveOnly05Pubkeys({ + context, + arr, + varName, +}: { + arr: Array; + varName: string; + context: string; +}) { + if (arr.some(v => !PubKey.is05Pubkey(v))) { + throw new PreConditionFailed(`${varName} did not contain only 05 pubkeys for ctx:"${context}"`); + } +} + +export const Preconditions = { checkUin8tArrayOrThrow, checkArrayHaveOnly05Pubkeys }; diff --git a/ts/state/ducks/groups.ts b/ts/state/ducks/groups.ts index df7a213a5..dccaae9b8 100644 --- a/ts/state/ducks/groups.ts +++ b/ts/state/ducks/groups.ts @@ -75,11 +75,18 @@ const initNewGroupInWrapper = createAsyncThunk( if (!members.includes(us)) { throw new PreConditionFailed('initNewGroupInWrapper needs us to be a member'); } - const uniqMembers = uniq(members); + if (members.some(k => !PubKey.is05Pubkey(k))) { + throw new PreConditionFailed('initNewGroupInWrapper only works with members being pubkeys'); + } + const uniqMembers = uniq(members) as Array; // the if just above ensures that this is fine const newGroup = await UserGroupsWrapperActions.createGroup(); const groupPk = newGroup.pubkeyHex; try { + const groupSecretKey = newGroup.secretKey; + if (!groupSecretKey) { + throw new Error('groupSecretKey was empty just after creation.'); + } newGroup.name = groupName; // this will be used by the linked devices until they fetch the info from the groups swarm // the `GroupSync` below will need the secretKey of the group to be saved in the wrapper. So save it! @@ -130,6 +137,7 @@ const initNewGroupInWrapper = createAsyncThunk( const result = await GroupSync.pushChangesToGroupSwarmIfNeeded(groupPk); if (result !== RunJobResult.Success) { window.log.warn('GroupSync.pushChangesToGroupSwarmIfNeeded during create failed'); + throw new Error('failed to pushChangesToGroupSwarmIfNeeded'); } await convo.unhideIfNeeded(); @@ -138,6 +146,23 @@ const initNewGroupInWrapper = createAsyncThunk( convo.updateLastMessage(); dispatch(resetOverlayMode()); await openConversationWithMessages({ conversationKey: groupPk, messageId: null }); + // everything is setup for this group, we now need to send the invites to every members, privately and asynchronously, and gracefully handle errors with toasts. + + const inviteDetails = await getGroupInvitesMessages({ + groupName, + membersFromWrapper, + secretKey: groupSecretKey, + groupPk, + }); + + void inviteDetails.map(async detail => { + await getMessageQueue().sendToPubKeyNonDurably({ + message: detail.invite, + namespace: SnodeNamespaces.Default, + pubkey: PubKey.cast(detail.member), + }); + console.log(`sending invite message to ${detail.member}`); + }); return { groupPk: newGroup.pubkeyHex, infos, members: membersFromWrapper }; } catch (e) { @@ -328,18 +353,19 @@ const groupSlice = createSlice({ state.infos[groupPk] = infos; state.members[groupPk] = members; state.creationFromUIPending = false; + return state; }); builder.addCase(initNewGroupInWrapper.rejected, state => { window.log.error('a initNewGroupInWrapper was rejected'); state.creationFromUIPending = false; - throw new Error('initNewGroupInWrapper.rejected'); - + return state; // FIXME delete the wrapper completely & corresponding dumps, and usergroups entry? }); builder.addCase(initNewGroupInWrapper.pending, (state, _action) => { state.creationFromUIPending = true; window.log.error('a initNewGroupInWrapper is pending'); + return state; }); builder.addCase(loadMetaDumpsFromDB.fulfilled, (state, action) => { const loaded = action.payload; @@ -347,9 +373,11 @@ const groupSlice = createSlice({ state.infos[element.groupPk] = element.infos; state.members[element.groupPk] = element.members; }); + return state; }); - builder.addCase(loadMetaDumpsFromDB.rejected, () => { + builder.addCase(loadMetaDumpsFromDB.rejected, state => { window.log.error('a loadMetaDumpsFromDB was rejected'); + return state; }); builder.addCase(refreshGroupDetailsFromWrapper.fulfilled, (state, action) => { const { infos, members, groupPk } = action.payload; @@ -367,6 +395,7 @@ const groupSlice = createSlice({ delete state.infos[groupPk]; delete state.members[groupPk]; } + return state; }); builder.addCase(refreshGroupDetailsFromWrapper.rejected, () => { window.log.error('a refreshGroupDetailsFromWrapper was rejected'); diff --git a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts index 003b12572..ea73fc35f 100644 --- a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts +++ b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { GroupMemberGet, MetaGroupWrapperNode, + PubkeyType, UserGroupsWrapperNode, } from 'libsession_util_nodejs'; import { range } from 'lodash'; @@ -15,7 +16,7 @@ function profilePicture() { return { key: new Uint8Array(range(0, 32)), url: `${Math.random()}` }; } -function emptyMember(pubkeyHex: string): GroupMemberGet { +function emptyMember(pubkeyHex: PubkeyType): GroupMemberGet { return { inviteFailed: false, invitePending: false, @@ -35,8 +36,8 @@ describe('libsession_metagroup', () => { let us: TestUserKeyPairs; let groupCreated: ReturnType; let metaGroupWrapper: MetaGroupWrapperNode; - let member: string; - let member2: string; + let member: PubkeyType; + let member2: PubkeyType; beforeEach(async () => { us = await TestUtils.generateUserKeyPairs(); diff --git a/ts/webworker/workers/browser/libsession_worker_interface.ts b/ts/webworker/workers/browser/libsession_worker_interface.ts index 357a10b81..1ca5fb7f7 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -12,6 +12,7 @@ import { MergeSingle, MetaGroupWrapperActionsCalls, ProfilePicture, + PubkeyType, UserConfigWrapperActionsCalls, UserGroupsSet, UserGroupsWrapperActionsCalls, @@ -407,11 +408,11 @@ export const MetaGroupWrapperActions: MetaGroupWrapperActionsCalls = { >, /** GroupMembers wrapper specific actions */ - memberGet: async (groupPk: GroupPubkeyType, pubkeyHex: string) => + memberGet: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType) => callLibSessionWorker([`MetaGroupConfig-${groupPk}`, 'memberGet', pubkeyHex]) as Promise< ReturnType >, - memberGetOrConstruct: async (groupPk: GroupPubkeyType, pubkeyHex: string) => + memberGetOrConstruct: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType) => callLibSessionWorker([ `MetaGroupConfig-${groupPk}`, 'memberGetOrConstruct', @@ -421,29 +422,29 @@ export const MetaGroupWrapperActions: MetaGroupWrapperActionsCalls = { callLibSessionWorker([`MetaGroupConfig-${groupPk}`, 'memberGetAll']) as Promise< ReturnType >, - memberErase: async (groupPk: GroupPubkeyType, pubkeyHex: string) => + memberErase: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType) => callLibSessionWorker([`MetaGroupConfig-${groupPk}`, 'memberErase', pubkeyHex]) as Promise< ReturnType >, - memberSetAccepted: async (groupPk: GroupPubkeyType, pubkeyHex: string) => + memberSetAccepted: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType) => callLibSessionWorker([`MetaGroupConfig-${groupPk}`, 'memberSetAccepted', pubkeyHex]) as Promise< ReturnType >, - memberSetPromoted: async (groupPk: GroupPubkeyType, pubkeyHex: string, failed: boolean) => + memberSetPromoted: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType, failed: boolean) => callLibSessionWorker([ `MetaGroupConfig-${groupPk}`, 'memberSetPromoted', pubkeyHex, failed, ]) as Promise>, - memberSetInvited: async (groupPk: GroupPubkeyType, pubkeyHex: string, failed: boolean) => + memberSetInvited: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType, failed: boolean) => callLibSessionWorker([ `MetaGroupConfig-${groupPk}`, 'memberSetInvited', pubkeyHex, failed, ]) as Promise>, - memberSetName: async (groupPk: GroupPubkeyType, pubkeyHex: string, name: string) => + memberSetName: async (groupPk: GroupPubkeyType, pubkeyHex: PubkeyType, name: string) => callLibSessionWorker([ `MetaGroupConfig-${groupPk}`, 'memberSetName', @@ -452,7 +453,7 @@ export const MetaGroupWrapperActions: MetaGroupWrapperActionsCalls = { ]) as Promise>, memberSetProfilePicture: async ( groupPk: GroupPubkeyType, - pubkeyHex: string, + pubkeyHex: PubkeyType, profilePicture: ProfilePicture ) => callLibSessionWorker([ @@ -498,6 +499,12 @@ export const MetaGroupWrapperActions: MetaGroupWrapperActionsCalls = { callLibSessionWorker([`MetaGroupConfig-${groupPk}`, 'decryptMessage', ciphertext]) as Promise< ReturnType >, + makeSwarmSubAccount: async (groupPk: GroupPubkeyType, memberPubkeyHex: PubkeyType) => + callLibSessionWorker([ + `MetaGroupConfig-${groupPk}`, + 'makeSwarmSubAccount', + memberPubkeyHex, + ]) as Promise>, }; export const callLibSessionWorker = async (