diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 86a73ba1b..65abd867b 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -1691,7 +1691,7 @@ export class ConversationModel extends Backbone.Model { try { const { body, attachments, preview, quote, fileIdsToLink } = await message.uploadData(); const { id } = message; - const destination = this.id; + const destination = this.id as string; const sentAt = message.get('sent_at'); if (!sentAt) { diff --git a/ts/session/apis/snode_api/SnodeRequestTypes.ts b/ts/session/apis/snode_api/SnodeRequestTypes.ts index e75eced7f..43a58e978 100644 --- a/ts/session/apis/snode_api/SnodeRequestTypes.ts +++ b/ts/session/apis/snode_api/SnodeRequestTypes.ts @@ -1,12 +1,14 @@ import { GroupPubkeyType } from 'libsession_util_nodejs'; import { SharedUserConfigMessage } from '../../messages/outgoing/controlMessage/SharedConfigMessage'; -import { SnodeNamespaces } from './namespaces'; +import { SnodeNamespaces, SnodeNamespacesGroup } from './namespaces'; export type SwarmForSubRequest = { method: 'get_swarm'; params: { pubkey: string } }; -type RetrieveMaxCountSize = { max_count?: number; max_size?: number }; +type WithMaxCountSize = { max_count?: number; max_size?: number }; +type WithPubkeyAsString = { pubkey: string }; +type WithPubkeyAsGroupPubkey = { pubkey: GroupPubkeyType }; + type RetrieveAlwaysNeeded = { - pubkey: string; namespace: number; last_hash: string; timestamp?: number; @@ -19,7 +21,8 @@ export type RetrievePubkeySubRequestType = { pubkey_ed25519: string; namespace: number; } & RetrieveAlwaysNeeded & - RetrieveMaxCountSize; + WithMaxCountSize & + WithPubkeyAsString; }; /** Those namespaces do not require to be authenticated for storing messages. @@ -35,23 +38,24 @@ export type RetrieveLegacyClosedGroupSubRequestType = { params: { namespace: SnodeNamespaces.LegacyClosedGroup; // legacy closed groups retrieve are not authenticated because the clients do not have a shared key } & RetrieveAlwaysNeeded & - RetrieveMaxCountSize; + WithMaxCountSize & + WithPubkeyAsString; }; -export type RetrieveSubKeySubRequestType = { +export type RetrieveGroupAdminSubRequestType = { method: 'retrieve'; params: { - subkey: string; // 32-byte hex encoded string signature: string; - namespace: number; + namespace: SnodeNamespacesGroup; } & RetrieveAlwaysNeeded & - RetrieveMaxCountSize; + WithMaxCountSize & + WithPubkeyAsGroupPubkey; }; export type RetrieveSubRequestType = | RetrieveLegacyClosedGroupSubRequestType | RetrievePubkeySubRequestType - | RetrieveSubKeySubRequestType + | RetrieveGroupAdminSubRequestType | UpdateExpiryOnNodeSubRequest; /** diff --git a/ts/session/apis/snode_api/namespaces.ts b/ts/session/apis/snode_api/namespaces.ts index 1c9ac770e..5bb74f3be 100644 --- a/ts/session/apis/snode_api/namespaces.ts +++ b/ts/session/apis/snode_api/namespaces.ts @@ -52,15 +52,25 @@ export enum SnodeNamespaces { ClosedGroupMembers = 14, } -export type SnodeNamespacesGroup = PickEnum< +export type SnodeNamespacesLegacyGroup = PickEnum< + SnodeNamespaces, + SnodeNamespaces.LegacyClosedGroup +>; + +type SnodeNamespacesGroupConfig = PickEnum< SnodeNamespaces, - | SnodeNamespaces.LegacyClosedGroup | SnodeNamespaces.ClosedGroupInfo | SnodeNamespaces.ClosedGroupMembers | SnodeNamespaces.ClosedGroupKeys - | SnodeNamespaces.Default >; +/** + * the namespaces to which a 03-group can store/retrieve messages from/to + */ +export type SnodeNamespacesGroup = + | SnodeNamespacesGroupConfig + | PickEnum; + export type SnodeNamespacesUser = PickEnum< SnodeNamespaces, SnodeNamespaces.UserContacts | SnodeNamespaces.UserProfile | SnodeNamespaces.Default @@ -72,9 +82,6 @@ export type SnodeNamespacesUser = PickEnum< // eslint-disable-next-line consistent-return function isUserConfigNamespace(namespace: SnodeNamespaces) { switch (namespace) { - case SnodeNamespaces.Default: - // user messages is not hosting config based messages - return false; case SnodeNamespaces.UserContacts: case SnodeNamespaces.UserProfile: case SnodeNamespaces.UserGroups: @@ -85,6 +92,8 @@ function isUserConfigNamespace(namespace: SnodeNamespaces) { case SnodeNamespaces.ClosedGroupMembers: case SnodeNamespaces.ClosedGroupMessages: case SnodeNamespaces.LegacyClosedGroup: + case SnodeNamespaces.Default: + // user messages is not hosting config based messages return false; default: @@ -97,7 +106,12 @@ function isUserConfigNamespace(namespace: SnodeNamespaces) { } } -function isGroupConfigNamespace(namespace: SnodeNamespaces) { +/** + * Returns true if that namespace is one of the namespace used for the 03-group config messages + */ +function isGroupConfigNamespace( + namespace: SnodeNamespaces +): namespace is SnodeNamespacesGroupConfig { switch (namespace) { case SnodeNamespaces.Default: case SnodeNamespaces.UserContacts: @@ -122,8 +136,37 @@ function isGroupConfigNamespace(namespace: SnodeNamespaces) { } } -// eslint-disable-next-line consistent-return -function namespacePriority(namespace: SnodeNamespaces): number { +/** + * + * @param namespace the namespace to check + * @returns true if that namespace is a valid namespace for a 03 group (either a config namespace or a message namespace) + */ +function isGroupNamespace(namespace: SnodeNamespaces): namespace is SnodeNamespacesGroup { + if (isGroupConfigNamespace(namespace)) { + return true; + } + if (namespace === SnodeNamespaces.ClosedGroupMessages) { + return true; + } + switch (namespace) { + case SnodeNamespaces.Default: + case SnodeNamespaces.UserContacts: + case SnodeNamespaces.UserProfile: + case SnodeNamespaces.UserGroups: + case SnodeNamespaces.ConvoInfoVolatile: + case SnodeNamespaces.LegacyClosedGroup: + return false; + default: + try { + assertUnreachable(namespace, `isGroupNamespace case not handled: ${namespace}`); + } catch (e) { + window.log.warn(`isGroupNamespace case not handled: ${namespace}: ${e.message}`); + return false; + } + } +} + +function namespacePriority(namespace: SnodeNamespaces): 10 | 1 { switch (namespace) { case SnodeNamespaces.Default: case SnodeNamespaces.ClosedGroupMessages: @@ -132,7 +175,6 @@ function namespacePriority(namespace: SnodeNamespaces): number { case SnodeNamespaces.ConvoInfoVolatile: case SnodeNamespaces.UserProfile: case SnodeNamespaces.UserContacts: - return 1; case SnodeNamespaces.LegacyClosedGroup: case SnodeNamespaces.ClosedGroupInfo: case SnodeNamespaces.ClosedGroupMembers: @@ -176,5 +218,6 @@ function maxSizeMap(namespaces: Array) { export const SnodeNamespace = { isUserConfigNamespace, isGroupConfigNamespace, + isGroupNamespace, maxSizeMap, }; diff --git a/ts/session/apis/snode_api/retrieveRequest.ts b/ts/session/apis/snode_api/retrieveRequest.ts index 17ec44fd1..6a36d2852 100644 --- a/ts/session/apis/snode_api/retrieveRequest.ts +++ b/ts/session/apis/snode_api/retrieveRequest.ts @@ -1,13 +1,17 @@ -import { omit } from 'lodash'; +import { isEmpty, isNil, omit } from 'lodash'; import { Snode } from '../../../data/data'; import { updateIsOnline } from '../../../state/ducks/onion'; import { doSnodeBatchRequest } from './batchRequest'; import { GetNetworkTime } from './getNetworkTime'; import { SnodeNamespace, SnodeNamespaces } from './namespaces'; +import { GroupPubkeyType } from 'libsession_util_nodejs'; +import { UserGroupsWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { DURATION } from '../../constants'; +import { PubKey } from '../../types'; import { UserUtils } from '../../utils'; import { + RetrieveGroupAdminSubRequestType, RetrieveLegacyClosedGroupSubRequestType, RetrieveSubRequestType, UpdateExpiryOnNodeSubRequest, @@ -15,6 +19,112 @@ import { import { SnodeSignature } from './snodeSignatures'; import { RetrieveMessagesResultsBatched, RetrieveMessagesResultsContent } from './types'; +type RetrieveParams = { + pubkey: string; + last_hash: string; + timestamp: number; + max_size: number | undefined; +}; + +async function retrieveRequestForUs({ + namespace, + ourPubkey, + retrieveParam, +}: { + ourPubkey: string; + namespace: SnodeNamespaces; + retrieveParam: RetrieveParams; +}) { + if (!SnodeNamespace.isUserConfigNamespace(namespace) && namespace !== SnodeNamespaces.Default) { + throw new Error(`retrieveRequestForUs not a valid namespace to retrieve as us:${namespace}`); + } + const signatureArgs = { ...retrieveParam, namespace, method: 'retrieve' as const, ourPubkey }; + const signatureBuilt = await SnodeSignature.getSnodeSignatureParamsUs(signatureArgs); + const retrieveForUS: RetrieveSubRequestType = { + method: 'retrieve', + params: { ...retrieveParam, namespace, ...signatureBuilt }, + }; + return retrieveForUS; +} + +/** + * Retrieve for legacy groups are not authenticated so no need to sign the request + */ +function retrieveRequestForLegacyGroup({ + namespace, + ourPubkey, + pubkey, + retrieveParam, +}: { + pubkey: string; + namespace: SnodeNamespaces.LegacyClosedGroup; + ourPubkey: string; + retrieveParam: RetrieveParams; +}) { + if (pubkey === ourPubkey || !pubkey.startsWith('05')) { + throw new Error( + 'namespace -10 can only be used to retrieve messages from a legacy closed group (prefix 05)' + ); + } + if (namespace !== SnodeNamespaces.LegacyClosedGroup) { + throw new Error(`retrieveRequestForLegacyGroup namespace can only be -10`); + } + const retrieveLegacyClosedGroup = { + ...retrieveParam, + namespace, + }; + const retrieveParamsLegacy: RetrieveLegacyClosedGroupSubRequestType = { + method: 'retrieve', + params: omit(retrieveLegacyClosedGroup, 'timestamp'), // if we give a timestamp, a signature will be required by the service node, and we don't want to provide one as this is an unauthenticated namespace + }; + + return retrieveParamsLegacy; +} + +/** + * Retrieve for groups (03-prefixed) are authenticated with the admin key if we have it, or with our subkey auth + */ +async function retrieveRequestForGroup({ + namespace, + groupPk, + retrieveParam, +}: { + groupPk: GroupPubkeyType; + namespace: SnodeNamespaces; + retrieveParam: RetrieveParams; +}) { + if (!PubKey.isClosedGroupV3(groupPk)) { + throw new Error('retrieveRequestForGroup: not a 03 group'); + } + if (!SnodeNamespace.isGroupNamespace(namespace)) { + throw new Error(`retrieveRequestForGroup: not a groupNamespace: ${namespace}`); + } + const group = await UserGroupsWrapperActions.getGroup(groupPk); + const groupSecretKey = group?.secretKey; + if (isNil(groupSecretKey) || isEmpty(groupSecretKey)) { + throw new Error(`sendMessagesDataToSnode: failed to find group admin secret key in wrapper`); + } + const signatureBuilt = await SnodeSignature.getSnodeGroupSignatureParams({ + ...retrieveParam, + namespace, + method: 'retrieve' as const, + groupPk, + groupIdentityPrivKey: groupSecretKey, + }); + + const retrieveGroup = { + ...retrieveParam, + ...signatureBuilt, + namespace, + }; + const retrieveParamsGroup: RetrieveGroupAdminSubRequestType = { + method: 'retrieve', + params: retrieveGroup, + }; + + return retrieveParamsGroup; +} + async function buildRetrieveRequest( lastHashes: Array, pubkey: string, @@ -29,47 +139,25 @@ async function buildRetrieveRequest( const retrieveParam = { pubkey, last_hash: lastHashes.at(index) || '', - namespace, timestamp: GetNetworkTime.getNowWithNetworkOffset(), max_size: foundMaxSize, }; if (namespace === SnodeNamespaces.LegacyClosedGroup) { - if (pubkey === ourPubkey || !pubkey.startsWith('05')) { - throw new Error( - 'namespace -10 can only be used to retrieve messages from a legacy closed group (prefix 05)' - ); + return retrieveRequestForLegacyGroup({ namespace, ourPubkey, pubkey, retrieveParam }); + } + + if (PubKey.isClosedGroupV3(pubkey)) { + if (!SnodeNamespace.isGroupNamespace(namespace)) { + // either config or messages namespaces for 03 groups + throw new Error(`tried to poll from a non 03 group namespace ${namespace}`); } - const retrieveLegacyClosedGroup = { - ...retrieveParam, - namespace, - }; - const retrieveParamsLegacy: RetrieveLegacyClosedGroupSubRequestType = { - method: 'retrieve', - params: omit(retrieveLegacyClosedGroup, 'timestamp'), // if we give a timestamp, a signature will be required by the service node, and we don't want to provide one as this is an unauthenticated namespace - }; - - return retrieveParamsLegacy; + return retrieveRequestForGroup({ namespace, groupPk: pubkey, retrieveParam }); } // all legacy closed group retrieves are unauthenticated and run above. // if we get here, this can only be a retrieve for our own swarm, which must be authenticated - if ( - !SnodeNamespace.isUserConfigNamespace(namespace) && - namespace !== SnodeNamespaces.Default - ) { - throw new Error(`not a legacy closed group. namespace can only be 0 and was ${namespace}`); - } - if (pubkey !== ourPubkey) { - throw new Error('not a legacy closed group. pubkey can only be ours'); - } - const signatureArgs = { ...retrieveParam, method: 'retrieve' as const, ourPubkey }; - const signatureBuilt = await SnodeSignature.getSnodeSignatureParamsUs(signatureArgs); - const retrieve: RetrieveSubRequestType = { - method: 'retrieve', - params: { ...retrieveParam, ...signatureBuilt }, - }; - return retrieve; + return retrieveRequestForUs({ namespace, ourPubkey, retrieveParam }); }) ); diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 8cb7b46e9..b8b460585 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -27,6 +27,8 @@ import { SnodeAPIRetrieve } from './retrieveRequest'; import { SwarmPollingGroupConfig } from './swarm_polling_config/SwarmPollingGroupConfig'; import { SwarmPollingUserConfig } from './swarm_polling_config/SwarmPollingUserConfig'; import { RetrieveMessageItem, RetrieveMessagesResultsBatched } from './types'; +import { GroupPubkeyType } from 'libsession_util_nodejs'; +import { assertUnreachable } from '../../../types/sqlSharedTypes'; export function extractWebSocketContent( message: string, @@ -62,6 +64,11 @@ export const getSwarmPollingInstance = () => { return instance; }; +type PollForUs = [pubkey: string, type: ConversationTypeEnum.PRIVATE]; +type PollForLegacy = [pubkey: string, type: ConversationTypeEnum.GROUP]; +type PollForGroup = [pubkey: GroupPubkeyType, type: ConversationTypeEnum.GROUPV3]; + + export class SwarmPolling { private groupPolling: Array<{ pubkey: PubKey; lastPolledTimestamp: number }>; private readonly lastHashes: Record>>; @@ -166,9 +173,8 @@ export class SwarmPolling { } // we always poll as often as possible for our pubkey const ourPubkey = UserUtils.getOurPubKeyStrFromCache(); - const userNamespaces = await this.getUserNamespacesPolled(); const directPromise = Promise.all([ - this.pollOnceForKey(ourPubkey, ConversationTypeEnum.PRIVATE, userNamespaces), + this.pollOnceForKey([ourPubkey, ConversationTypeEnum.PRIVATE]), ]).then(() => undefined); const now = Date.now(); @@ -176,7 +182,6 @@ export class SwarmPolling { const convoPollingTimeout = this.getPollingTimeout(group.pubkey); const diff = now - group.lastPolledTimestamp; const { key } = group.pubkey; - const isV3 = PubKey.isClosedGroupV3(key); const loggingId = getConversationController() @@ -186,17 +191,10 @@ export class SwarmPolling { window?.log?.debug( `Polling for ${loggingId}; timeout: ${convoPollingTimeout}; diff: ${diff} ` ); - if (isV3) { - return this.pollOnceForKey(key, ConversationTypeEnum.GROUPV3, [ - SnodeNamespaces.Default, - SnodeNamespaces.ClosedGroupInfo, - SnodeNamespaces.ClosedGroupMembers, - SnodeNamespaces.ClosedGroupKeys, // keys are fetched last to avoid race conditions when someone deposits them - ]); + if (PubKey.isClosedGroupV3(key)) { + return this.pollOnceForKey([key, ConversationTypeEnum.GROUPV3]); } - return this.pollOnceForKey(key, ConversationTypeEnum.GROUP, [ - SnodeNamespaces.LegacyClosedGroup, - ]); + return this.pollOnceForKey([key, ConversationTypeEnum.GROUP]); } window?.log?.debug( `Not polling for ${loggingId}; timeout: ${convoPollingTimeout} ; diff: ${diff}` @@ -219,10 +217,10 @@ export class SwarmPolling { * Only exposed as public for testing */ public async pollOnceForKey( - pubkey: string, - type: ConversationTypeEnum, - namespaces: Array + [pubkey, type]:PollForUs | PollForLegacy | PollForGroup ) { + const namespaces = this.getNamespacesToPollFrom(type); + const swarmSnodes = await snodePool.getSwarmFor(pubkey); // Select nodes for which we already have lastHashes @@ -469,14 +467,31 @@ export class SwarmPolling { return newMessages; } - private async getUserNamespacesPolled() { + + private getNamespacesToPollFrom(type: ConversationTypeEnum): Array { + if(type === ConversationTypeEnum.PRIVATE) { return [ SnodeNamespaces.Default, SnodeNamespaces.UserProfile, SnodeNamespaces.UserContacts, SnodeNamespaces.UserGroups, SnodeNamespaces.ConvoInfoVolatile, - ]; + ] ; + } + if(type === ConversationTypeEnum.GROUP) { + return [ + SnodeNamespaces.LegacyClosedGroup + ] ; + } + if(type === ConversationTypeEnum.GROUPV3) { + return [ + SnodeNamespaces.ClosedGroupMessages, + SnodeNamespaces.ClosedGroupInfo, + SnodeNamespaces.ClosedGroupMembers, + SnodeNamespaces.ClosedGroupKeys, // keys are fetched last to avoid race conditions when someone deposits them + ] ; + } + assertUnreachable(type, `getNamespacesToPollFrom case should have been unreachable: type:${type}`) } private async updateLastHash({ diff --git a/ts/session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage.ts b/ts/session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage.ts index 11e248d76..203137ca4 100644 --- a/ts/session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage.ts +++ b/ts/session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage.ts @@ -6,7 +6,7 @@ import { ClosedGroupMessage } from '../controlMessage/group/ClosedGroupMessage'; interface ClosedGroupVisibleMessageParams { identifier?: string; - groupId: string | PubKey; + groupId: string; chatMessage: VisibleMessage; } diff --git a/ts/session/onions/onionPath.ts b/ts/session/onions/onionPath.ts index d6df1e60b..ae9984d04 100644 --- a/ts/session/onions/onionPath.ts +++ b/ts/session/onions/onionPath.ts @@ -63,7 +63,8 @@ const pathFailureThreshold = 3; // some naming issue here it seems) export let guardNodes: Array = []; -export const ed25519Str = (ed25519Key: string) => `(...${ed25519Key.substr(58)})`; +export const ed25519Str = (ed25519Key: string) => + `(${ed25519Key.substr(0, 2)}...${ed25519Key.substr(60)})`; export async function buildNewOnionPathsOneAtATime() { // this function may be called concurrently make sure we only have one inflight diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index 1ac531d71..d59de9521 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -27,7 +27,7 @@ import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroup import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction'; import { SnodeNamespaces, - SnodeNamespacesGroup, + SnodeNamespacesLegacyGroup, SnodeNamespacesUser, } from '../apis/snode_api/namespaces'; import { SharedConfigMessage } from '../messages/outgoing/controlMessage/SharedConfigMessage'; @@ -179,7 +179,7 @@ export class MessageQueue { sentCb, }: { message: ClosedGroupMessageType; - namespace: SnodeNamespacesGroup; + namespace: SnodeNamespacesLegacyGroup; sentCb?: (message: RawMessage) => Promise; groupPubKey?: PubKey; }): Promise { diff --git a/ts/test/session/unit/messages/closed_groups/ClosedGroupChatMessage_test.ts b/ts/test/session/unit/messages/closed_groups/ClosedGroupChatMessage_test.ts index 98dac9505..53ec10620 100644 --- a/ts/test/session/unit/messages/closed_groups/ClosedGroupChatMessage_test.ts +++ b/ts/test/session/unit/messages/closed_groups/ClosedGroupChatMessage_test.ts @@ -9,9 +9,9 @@ import { ClosedGroupVisibleMessage } from '../../../../../session/messages/outgo import { VisibleMessage } from '../../../../../session/messages/outgoing/visibleMessage/VisibleMessage'; describe('ClosedGroupVisibleMessage', () => { - let groupId: PubKey; + let groupId: string; beforeEach(() => { - groupId = TestUtils.generateFakePubKey(); + groupId = TestUtils.generateFakePubKeyStr(); }); it('can create empty message with timestamp, groupId and chatMessage', () => { const chatMessage = new VisibleMessage({ @@ -28,7 +28,7 @@ describe('ClosedGroupVisibleMessage', () => { .to.have.property('group') .to.have.deep.property( 'id', - new Uint8Array(StringUtils.encode(PubKey.PREFIX_GROUP_TEXTSECURE + groupId.key, 'utf8')) + new Uint8Array(StringUtils.encode(PubKey.PREFIX_GROUP_TEXTSECURE + groupId, 'utf8')) ); expect(decoded.dataMessage) .to.have.property('group') diff --git a/ts/test/session/unit/utils/Messages_test.ts b/ts/test/session/unit/utils/Messages_test.ts index 9622a34f5..12c085303 100644 --- a/ts/test/session/unit/utils/Messages_test.ts +++ b/ts/test/session/unit/utils/Messages_test.ts @@ -97,7 +97,7 @@ describe('Message Utils', () => { it('should set encryption to ClosedGroup if a ClosedGroupVisibleMessage is passed in', async () => { const device = TestUtils.generateFakePubKey(); - const groupId = TestUtils.generateFakePubKey(); + const groupId = TestUtils.generateFakePubKeyStr(); const chatMessage = TestUtils.generateVisibleMessage(); const message = new ClosedGroupVisibleMessage({ chatMessage, groupId });