From 0a3d71fe0321b3323b515a4527d77f19f387be7c Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 4 Jun 2024 17:08:54 +1000 Subject: [PATCH] feat: delete msg on swarm when admin receives member request --- ts/data/sharedDataTypes.ts | 2 +- .../conversations/unsendingInteractions.ts | 11 +- ts/node/sql.ts | 14 +- ts/receiver/groupv2/handleGroupV2Message.ts | 26 +- ts/session/apis/snode_api/SNodeAPI.ts | 488 ++++++++++-------- .../apis/snode_api/SnodeRequestTypes.ts | 44 +- .../snode_api/signature/groupSignature.ts | 3 +- ts/session/sending/MessageSender.ts | 52 +- .../jobs/GroupPendingRemovalsJob.ts | 7 +- .../utils/job_runners/jobs/GroupSyncJob.ts | 106 +++- .../utils/job_runners/jobs/UserSyncJob.ts | 9 +- 11 files changed, 453 insertions(+), 309 deletions(-) diff --git a/ts/data/sharedDataTypes.ts b/ts/data/sharedDataTypes.ts index b7ba12572..881a0c486 100644 --- a/ts/data/sharedDataTypes.ts +++ b/ts/data/sharedDataTypes.ts @@ -24,4 +24,4 @@ export type DeleteAllMessageHashesInConversationMatchingAuthorType = ( author: PubkeyType; signatureTimestamp: number; } -) => PrArrayMsgIds; +) => Promise<{ msgIdsDeleted: Array; msgHashesDeleted: Array }>; diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index 7f70aef2c..c777a4173 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -126,7 +126,7 @@ async function unsendMessagesForEveryone( await unsendMessagesForEveryone1o1AndLegacy(conversation, conversation.id, msgsToDelete); } else if (conversation.isClosedGroupV2()) { if (!PubKey.is03Pubkey(destinationId)) { - throw new Error('invalid conversation id (03) for unsendMessageForEveryone'); + throw new Error('invalid conversation id (03) for unsendMessageForEveryone'); } await unsendMessagesForEveryoneGroupV2({ groupPk: destinationId, @@ -199,11 +199,10 @@ export async function deleteMessagesFromSwarmOnly( ); return false; } - const errorOnAtLeastOneSnode = await SnodeAPI.networkDeleteMessages( - deletionMessageHashes, - pubkey - ); - return errorOnAtLeastOneSnode; + if (PubKey.is03Pubkey(pubkey)) { + return await SnodeAPI.networkDeleteMessagesForGroup(deletionMessageHashes, pubkey); + } + return await SnodeAPI.networkDeleteMessageOurSwarm(deletionMessageHashes, pubkey); } catch (e) { window.log?.error( `deleteMessagesFromSwarmOnly: Error deleting message from swarm of ${ed25519Str(pubkey)}, hashes: ${deletionMessageHashes}`, diff --git a/ts/node/sql.ts b/ts/node/sql.ts index 5c20dd8c3..5a886cb66 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -1134,14 +1134,18 @@ function deleteAllMessageHashesInConversationMatchingAuthor( instance?: BetterSqlite3.Database ): AwaitedReturn { if (!groupPk || !author || !messageHashes.length) { - return []; + return { msgHashesDeleted: [], msgIdsDeleted: [] }; } - return assertGlobalInstanceOrInstance(instance) + const results = assertGlobalInstanceOrInstance(instance) .prepare( - `DELETE FROM ${MESSAGES_TABLE} WHERE conversationId = ? AND source = ? AND sent_at <= ? AND messageHash IN ( ${messageHashes.map(() => '?').join(', ')} ) RETURNING id` + `DELETE FROM ${MESSAGES_TABLE} WHERE conversationId = ? AND source = ? AND sent_at <= ? AND messageHash IN ( ${messageHashes.map(() => '?').join(', ')} ) RETURNING id, messageHash;` ) - .all(groupPk, author, signatureTimestamp, ...messageHashes) - .map(m => m.id); + .all(groupPk, author, signatureTimestamp, ...messageHashes); + + return { + msgHashesDeleted: results.map(m => m.messageHash), + msgIdsDeleted: results.map(m => m.id), + }; } function cleanUpExpirationTimerUpdateHistory( diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index f6261093d..9f35e1149 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -1,6 +1,7 @@ import { GroupPubkeyType, PubkeyType, WithGroupPubkey } from 'libsession_util_nodejs'; import { isEmpty, isFinite, isNumber } from 'lodash'; import { Data } from '../../data/data'; +import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/unsendingInteractions'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { HexString } from '../../node/hexStrings'; import { SignalService } from '../../protobuf'; @@ -377,16 +378,27 @@ async function handleGroupDeleteMemberContentMessage({ if (isEmpty(change.adminSignature)) { // this is step 1. - const msgsDeleted = await Data.deleteAllMessageHashesInConversationMatchingAuthor({ - author, - groupPk, - messageHashes: change.messageHashes, - signatureTimestamp, - }); + const { msgIdsDeleted, msgHashesDeleted } = + await Data.deleteAllMessageHashesInConversationMatchingAuthor({ + author, + groupPk, + messageHashes: change.messageHashes, + signatureTimestamp, + }); window.inboxStore.dispatch( - messagesExpired(msgsDeleted.map(m => ({ conversationKey: groupPk, messageId: m }))) + messagesExpired(msgIdsDeleted.map(m => ({ conversationKey: groupPk, messageId: m }))) ); + + if (msgIdsDeleted.length) { + // Note: we `void` it because we don't want to hang while + // processing the handleGroupDeleteMemberContentMessage itself + // (we are running on the receiving pipeline here) + void deleteMessagesFromSwarmOnly(msgHashesDeleted, groupPk).catch(e => { + // we retry a bunch of times already, so if it still fails, there is not much we can do. + window.log.warn('deleteMessagesFromSwarmOnly failed with', e.message); + }); + } convo.updateLastMessage(); return; } diff --git a/ts/session/apis/snode_api/SNodeAPI.ts b/ts/session/apis/snode_api/SNodeAPI.ts index 7b877d33a..47d053a76 100644 --- a/ts/session/apis/snode_api/SNodeAPI.ts +++ b/ts/session/apis/snode_api/SNodeAPI.ts @@ -14,7 +14,7 @@ import { BatchRequests } from './batchRequest'; import { SnodePool } from './snodePool'; import { StringUtils, UserUtils } from '../../utils'; import { ed25519Str, fromBase64ToArray, fromHexToArray } from '../../utils/String'; - +import { UserGroupsWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; export const ERROR_CODE_NO_CONNECT = 'ENETUNREACH: No network connection.'; @@ -30,124 +30,111 @@ const forceNetworkDeletion = async (): Promise | null> => { async () => { const snodeToMakeRequestTo = await SnodePool.getNodeFromSwarmOrThrow(usPk); const builtRequest = await request.buildAndSignParameters(); // we need the timestamp to verify the signature below - return pRetry( - async () => { - const ret = await BatchRequests.doSnodeBatchRequestNoRetries( - [builtRequest], - snodeToMakeRequestTo, - 10000, - usPk, - false - ); + const ret = await BatchRequests.doSnodeBatchRequestNoRetries( + [builtRequest], + snodeToMakeRequestTo, + 10000, + usPk, + false + ); - if (!ret || !ret?.[0].body || ret[0].code !== 200) { - throw new Error( - `Empty response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}` - ); - } - - try { - const firstResultParsedBody = ret[0].body; - const { swarm } = firstResultParsedBody; - - if (!swarm) { - throw new Error( - `Invalid JSON swarm response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}, ${firstResultParsedBody}` - ); - } - const swarmAsArray = Object.entries(swarm) as Array>; - if (!swarmAsArray.length) { - throw new Error( - `Invalid JSON swarmAsArray response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}, ${firstResultParsedBody}` - ); - } - // results will only contains the snode pubkeys which returned invalid/empty results - const results: Array = compact( - swarmAsArray.map(snode => { - const snodePubkey = snode[0]; - const snodeJson = snode[1]; - - const isFailed = snodeJson.failed || false; - - if (isFailed) { - const reason = snodeJson.reason; - const statusCode = snodeJson.code; - if (reason && statusCode) { - window?.log?.warn( - `Could not ${request.method} from ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )} due to error: ${reason}: ${statusCode}` - ); - // if we tried to make the delete on a snode not in our swarm, just trigger a pRetry error so the outer block here finds new snodes to make the request to. - if (statusCode === 421) { - throw new pRetry.AbortError( - `421 error on network ${request.method}. Retrying with a new snode` - ); - } - } else { - window?.log?.warn( - `Could not ${request.method} from ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}` - ); - } - return snodePubkey; - } + if (!ret || !ret?.[0].body || ret[0].code !== 200) { + throw new Error( + `Empty response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}` + ); + } - const deletedObj = snodeJson.deleted as Record>; - const hashes: Array = []; + try { + const firstResultParsedBody = ret[0].body; + const { swarm } = firstResultParsedBody; - for (const key in deletedObj) { - if (deletedObj.hasOwnProperty(key)) { - hashes.push(...deletedObj[key]); - } - } - const sortedHashes = hashes.sort(); - const signatureSnode = snodeJson.signature as string; - // The signature format is (with sortedHashes accross all namespaces) ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - const dataToVerify = `${usPk}${builtRequest.params.timestamp}${sortedHashes.join('')}`; - - const dataToVerifyUtf8 = StringUtils.encode(dataToVerify, 'utf8'); - const isValid = sodium.crypto_sign_verify_detached( - fromBase64ToArray(signatureSnode), - new Uint8Array(dataToVerifyUtf8), - fromHexToArray(snodePubkey) + if (!swarm) { + throw new Error( + `Invalid JSON swarm response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${firstResultParsedBody}` + ); + } + const swarmAsArray = Object.entries(swarm) as Array>; + if (!swarmAsArray.length) { + throw new Error( + `Invalid JSON swarmAsArray response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${firstResultParsedBody}` + ); + } + // results will only contains the snode pubkeys which returned invalid/empty results + const results: Array = compact( + swarmAsArray.map(snode => { + const snodePubkey = snode[0]; + const snodeJson = snode[1]; + + const isFailed = snodeJson.failed || false; + + if (isFailed) { + const reason = snodeJson.reason; + const statusCode = snodeJson.code; + if (reason && statusCode) { + window?.log?.warn( + `Could not ${request.method} from ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )} due to error: ${reason}: ${statusCode}` ); - if (!isValid) { - return snodePubkey; + // if we tried to make the delete on a snode not in our swarm, just trigger a pRetry error so the outer block here finds new snodes to make the request to. + if (statusCode === 421) { + throw new pRetry.AbortError( + `421 error on network ${request.method}. Retrying with a new snode` + ); } - return null; - }) - ); + } else { + window?.log?.warn( + `Could not ${request.method} from ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}` + ); + } + return snodePubkey; + } - return results; - } catch (e) { - throw new Error( - `Invalid JSON response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}, ${ret}` - ); - } - }, - { - retries: 3, - minTimeout: SnodeAPI.TEST_getMinTimeout(), - onFailedAttempt: e => { - window?.log?.warn( - `${request.method} INNER request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` + const deletedObj = snodeJson.deleted as Record>; + const hashes: Array = []; + + for (const key in deletedObj) { + if (deletedObj.hasOwnProperty(key)) { + hashes.push(...deletedObj[key]); + } + } + const sortedHashes = hashes.sort(); + const signatureSnode = snodeJson.signature as string; + // The signature format is (with sortedHashes accross all namespaces) ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) + const dataToVerify = `${usPk}${builtRequest.params.timestamp}${sortedHashes.join('')}`; + + const dataToVerifyUtf8 = StringUtils.encode(dataToVerify, 'utf8'); + const isValid = sodium.crypto_sign_verify_detached( + fromBase64ToArray(signatureSnode), + new Uint8Array(dataToVerifyUtf8), + fromHexToArray(snodePubkey) ); - }, - } - ); + if (!isValid) { + return snodePubkey; + } + return null; + }) + ); + + return results; + } catch (e) { + throw new Error( + `Invalid JSON response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${ret}` + ); + } }, { - retries: 3, + retries: 5, minTimeout: SnodeAPI.TEST_getMinTimeout(), onFailedAttempt: e => { window?.log?.warn( @@ -167,141 +154,120 @@ const forceNetworkDeletion = async (): Promise | null> => { const TEST_getMinTimeout = () => 500; /** - * Locally deletes message and deletes message on the network (all nodes that contain the message) + * Delete the specified message hashes from the our own swarm only. + * Note: legacy group did not support removing messages from the swarm. */ -const networkDeleteMessages = async ( +const networkDeleteMessageOurSwarm = async ( messagesHashes: Array, - pubkey: PubkeyType | GroupPubkeyType + pubkey: PubkeyType ): Promise => { const sodium = await getSodiumRenderer(); - if (PubKey.is05Pubkey(pubkey) && pubkey !== UserUtils.getOurPubKeyStrFromCache()) { - throw new Error('networkDeleteMessages with 05 pk can only delete for ourself'); + if (!PubKey.is05Pubkey(pubkey) || pubkey !== UserUtils.getOurPubKeyStrFromCache()) { + throw new Error('networkDeleteMessageOurSwarm with 05 pk can only for our own swarm'); } - const request = PubKey.is03Pubkey(pubkey) - ? new DeleteHashesFromGroupNodeSubRequest({ messagesHashes, groupPk: pubkey }) - : new DeleteHashesFromUserNodeSubRequest({ messagesHashes }); + const request = new DeleteHashesFromUserNodeSubRequest({ messagesHashes }); try { const success = await pRetry( async () => { const snodeToMakeRequestTo = await SnodePool.getNodeFromSwarmOrThrow(request.pubkey); - return pRetry( - async () => { - const ret = await BatchRequests.doUnsignedSnodeBatchRequestNoRetries( - [request], - snodeToMakeRequestTo, - 10000, - request.pubkey, - false + const ret = await BatchRequests.doUnsignedSnodeBatchRequestNoRetries( + [request], + snodeToMakeRequestTo, + 10000, + request.pubkey, + false + ); + + if (!ret || !ret?.[0].body || ret[0].code !== 200) { + throw new Error( + `networkDeleteMessageOurSwarm: Empty response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )} about pk: ${ed25519Str(request.pubkey)}` + ); + } + + try { + const firstResultParsedBody = ret[0].body; + const { swarm } = firstResultParsedBody; + + if (!swarm) { + throw new Error( + `networkDeleteMessageOurSwarm: Invalid JSON swarm response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${firstResultParsedBody}` ); - debugger; + } + const swarmAsArray = Object.entries(swarm) as Array>; + if (!swarmAsArray.length) { + throw new Error( + `networkDeleteMessageOurSwarm: Invalid JSON swarmAsArray response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${firstResultParsedBody}` + ); + } + // results will only contains the snode pubkeys which returned invalid/empty results + const results: Array = compact( + swarmAsArray.map(snode => { + const snodePubkey = snode[0]; + const snodeJson = snode[1]; - if (!ret || !ret?.[0].body || ret[0].code !== 200) { - throw new Error( - `Empty response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )} about pk: ${ed25519Str(request.pubkey)}` - ); - } - - try { - const firstResultParsedBody = ret[0].body; - const { swarm } = firstResultParsedBody; - - if (!swarm) { - throw new Error( - `Invalid JSON swarm response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}, ${firstResultParsedBody}` - ); - } - const swarmAsArray = Object.entries(swarm) as Array>; - if (!swarmAsArray.length) { - throw new Error( - `Invalid JSON swarmAsArray response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}, ${firstResultParsedBody}` - ); - } - // results will only contains the snode pubkeys which returned invalid/empty results - const results: Array = compact( - swarmAsArray.map(snode => { - const snodePubkey = snode[0]; - const snodeJson = snode[1]; - - const isFailed = snodeJson.failed || false; - - if (isFailed) { - const reason = snodeJson.reason; - const statusCode = snodeJson.code; - if (reason && statusCode) { - window?.log?.warn( - `Could not ${request.method} from ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )} due to error: ${reason}: ${statusCode}` - ); - // if we tried to make the delete on a snode not in our swarm, just trigger a pRetry error so the outer block here finds new snodes to make the request to. - if (statusCode === 421) { - throw new pRetry.AbortError( - `421 error on network ${request.method}. Retrying with a new snode` - ); - } - } else { - window?.log?.warn( - `Could not ${request.method} from ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}` - ); - } - return snodePubkey; - } + const isFailed = snodeJson.failed || false; - const responseHashes = snodeJson.deleted as Array; - const signatureSnode = snodeJson.signature as string; - // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - const dataToVerify = `${request.pubkey}${messagesHashes.join( - '' - )}${responseHashes.join('')}`; - const dataToVerifyUtf8 = StringUtils.encode(dataToVerify, 'utf8'); - const isValid = sodium.crypto_sign_verify_detached( - fromBase64ToArray(signatureSnode), - new Uint8Array(dataToVerifyUtf8), - fromHexToArray(snodePubkey) + if (isFailed) { + const reason = snodeJson.reason; + const statusCode = snodeJson.code; + if (reason && statusCode) { + window?.log?.warn( + `networkDeleteMessageOurSwarm: Could not ${request.method} from ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )} due to error: ${reason}: ${statusCode}` ); - if (!isValid) { - return snodePubkey; - } - return null; - }) - ); + } else { + window?.log?.warn( + `networkDeleteMessageOurSwarm: Could not ${request.method} from ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}` + ); + } + return snodePubkey; + } - return isEmpty(results); - } catch (e) { - throw new Error( - `Invalid JSON response got for ${request.method} on snode ${ed25519Str( - snodeToMakeRequestTo.pubkey_ed25519 - )}, ${ret}` - ); - } - }, - { - retries: 3, - minTimeout: SnodeAPI.TEST_getMinTimeout(), - onFailedAttempt: e => { - window?.log?.warn( - `${request.method} INNER request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` + const responseHashes = snodeJson.deleted as Array; + const signatureSnode = snodeJson.signature as string; + // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + const dataToVerify = `${request.pubkey}${messagesHashes.join( + '' + )}${responseHashes.join('')}`; + const dataToVerifyUtf8 = StringUtils.encode(dataToVerify, 'utf8'); + const isValid = sodium.crypto_sign_verify_detached( + fromBase64ToArray(signatureSnode), + new Uint8Array(dataToVerifyUtf8), + fromHexToArray(snodePubkey) ); - }, - } - ); + if (!isValid) { + return snodePubkey; + } + return null; + }) + ); + + return isEmpty(results); + } catch (e) { + throw new Error( + `networkDeleteMessageOurSwarm: Invalid JSON response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${ret}` + ); + } }, { - retries: 3, + retries: 5, minTimeout: SnodeAPI.TEST_getMinTimeout(), onFailedAttempt: e => { window?.log?.warn( - `${request.method} OUTER request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left... ${e.message}` + `networkDeleteMessageOurSwarm: ${request.method} request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left... ${e.message}` ); }, } @@ -309,13 +275,85 @@ const networkDeleteMessages = async ( return success; } catch (e) { - window?.log?.warn(`failed to ${request.method} message on network:`, e); + window?.log?.warn( + `networkDeleteMessageOurSwarm: failed to ${request.method} message on network:`, + e + ); + return false; + } +}; + +/** + * Delete the specified message hashes from the 03-group's swarm. + * Returns true when the hashes have been removed successufuly. + * Returns false when + * - we don't have the secretKey + * - if one of the hash was already not present in the swarm, + * - if the request failed too many times + */ +const networkDeleteMessagesForGroup = async ( + messagesHashes: Array, + groupPk: GroupPubkeyType +): Promise => { + if (!PubKey.is03Pubkey(groupPk)) { + throw new Error('networkDeleteMessagesForGroup with 05 pk can only delete for ourself'); + } + const group = await UserGroupsWrapperActions.getGroup(groupPk); + if (!group || !group.secretKey || isEmpty(group.secretKey)) { + window.log.warn( + `networkDeleteMessagesForGroup: not deleting from swarm of 03-group ${messagesHashes.length} hashes as we do not the adminKey` + ); + return false; + } + + try { + const request = new DeleteHashesFromGroupNodeSubRequest({ + messagesHashes, + groupPk, + secretKey: group.secretKey, + }); + + await pRetry( + async () => { + const snodeToMakeRequestTo = await SnodePool.getNodeFromSwarmOrThrow(request.pubkey); + + const ret = await BatchRequests.doUnsignedSnodeBatchRequestNoRetries( + [request], + snodeToMakeRequestTo, + 10000, + request.pubkey, + false + ); + + if (!ret || !ret?.[0].body || ret[0].code !== 200) { + throw new Error( + `networkDeleteMessagesForGroup: Empty response got for ${request.method} on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )} about pk: ${ed25519Str(request.pubkey)}` + ); + } + }, + { + retries: 5, + minTimeout: SnodeAPI.TEST_getMinTimeout(), + onFailedAttempt: e => { + window?.log?.warn( + `networkDeleteMessagesForGroup: ${request.method} request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left... ${e.message}` + ); + }, + } + ); + + return true; + } catch (e) { + window?.log?.warn(`networkDeleteMessagesForGroup: failed to delete messages on network:`, e); return false; } }; export const SnodeAPI = { TEST_getMinTimeout, - networkDeleteMessages, + networkDeleteMessagesForGroup, + networkDeleteMessageOurSwarm, forceNetworkDeletion, }; diff --git a/ts/session/apis/snode_api/SnodeRequestTypes.ts b/ts/session/apis/snode_api/SnodeRequestTypes.ts index 512af0ac5..d0cf76a1c 100644 --- a/ts/session/apis/snode_api/SnodeRequestTypes.ts +++ b/ts/session/apis/snode_api/SnodeRequestTypes.ts @@ -3,10 +3,10 @@ import { GroupPubkeyType, PubkeyType, WithGroupPubkey } from 'libsession_util_no import { from_hex } from 'libsodium-wrappers-sumo'; import { isEmpty, isString } from 'lodash'; import { AwaitedReturn, assertUnreachable } from '../../../types/sqlSharedTypes'; -import { UserGroupsWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { concatUInt8Array } from '../../crypto'; import { PubKey } from '../../types'; import { StringUtils, UserUtils } from '../../utils'; +import { ed25519Str } from '../../utils/String'; import { GetNetworkTime } from './getNetworkTime'; import { SnodeNamespace, @@ -25,7 +25,6 @@ import { WithSignature, WithTimestamp, } from './types'; -import { ed25519Str } from '../../utils/String'; type WithMaxSize = { max_size?: number }; export type WithShortenOrExtend = { shortenOrExtend: 'shorten' | 'extend' | '' }; @@ -559,25 +558,25 @@ export class DeleteHashesFromGroupNodeSubRequest extends SnodeAPISubRequest { public method = 'delete' as const; public readonly messageHashes: Array; public readonly pubkey: GroupPubkeyType; + public readonly secretKey: Uint8Array; - constructor(args: WithMessagesHashes & WithGroupPubkey) { + constructor(args: WithMessagesHashes & WithGroupPubkey & WithSecretKey) { super(); this.messageHashes = args.messagesHashes; this.pubkey = args.groupPk; + this.secretKey = args.secretKey; + if (!this.secretKey || isEmpty(this.secretKey)) { + throw new Error('DeleteHashesFromGroupNodeSubRequest needs a secretKey'); + } } public async buildAndSignParameters() { - const group = await UserGroupsWrapperActions.getGroup(this.pubkey); - if (!group) { - throw new Error('DeleteHashesFromGroupNodeSubRequest no such group found'); - } - // This will try to use the adminSecretKey if we have it, or the authData if we have it. - // Otherwise, it will throw + // Note: this request can only be made by an admin and will be denied otherwise, so we make the secretKey mandatory in the constructor. const signResult = await SnodeGroupSignature.getGroupSignatureByHashesParams({ method: this.method, messagesHashes: this.messageHashes, groupPk: this.pubkey, - group, + group: { authData: null, pubkeyHex: this.pubkey, secretKey: this.secretKey }, }); return { @@ -714,8 +713,9 @@ export class StoreGroupConfigOrMessageSubRequest extends SnodeAPISubRequest { public readonly destination: GroupPubkeyType; public readonly ttlMs: number; public readonly encryptedData: Uint8Array; - public readonly dbMessageIdentifier: string | null; + public readonly secretKey: Uint8Array | null; + public readonly authData: Uint8Array | null; constructor( args: WithGroupPubkey & { @@ -726,6 +726,8 @@ export class StoreGroupConfigOrMessageSubRequest extends SnodeAPISubRequest { ttlMs: number; encryptedData: Uint8Array; dbMessageIdentifier: string | null; + authData: Uint8Array | null; + secretKey: Uint8Array | null; } ) { super(); @@ -734,6 +736,8 @@ export class StoreGroupConfigOrMessageSubRequest extends SnodeAPISubRequest { this.ttlMs = args.ttlMs; this.encryptedData = args.encryptedData; this.dbMessageIdentifier = args.dbMessageIdentifier; + this.authData = args.authData; + this.secretKey = args.secretKey; if (isEmpty(this.encryptedData)) { throw new Error('this.encryptedData cannot be empty'); @@ -743,6 +747,16 @@ export class StoreGroupConfigOrMessageSubRequest extends SnodeAPISubRequest { 'StoreGroupConfigOrMessageSubRequest: groupconfig namespace required a 03 pubkey' ); } + if (isEmpty(this.secretKey) && isEmpty(this.authData)) { + throw new Error( + 'StoreGroupConfigOrMessageSubRequest needs either authData or secretKey to be set' + ); + } + if (SnodeNamespace.isGroupConfigNamespace(this.namespace) && isEmpty(this.secretKey)) { + throw new Error( + `StoreGroupConfigOrMessageSubRequest: groupconfig namespace [${this.namespace}] requires an adminSecretKey` + ); + } } public async buildAndSignParameters(): Promise<{ @@ -751,17 +765,11 @@ export class StoreGroupConfigOrMessageSubRequest extends SnodeAPISubRequest { }> { const encryptedDataBase64 = ByteBuffer.wrap(this.encryptedData).toString('base64'); - const found = await UserGroupsWrapperActions.getGroup(this.destination); - if (SnodeNamespace.isGroupConfigNamespace(this.namespace) && isEmpty(found?.secretKey)) { - throw new Error( - `groupconfig namespace [${this.namespace}] require an adminSecretKey for signature but we found none` - ); - } // this will either sign with our admin key or with the subaccount key if the admin one isn't there const signDetails = await SnodeGroupSignature.getSnodeGroupSignature({ method: this.method, namespace: this.namespace, - group: found, + group: { authData: this.authData, pubkeyHex: this.destination, secretKey: this.secretKey }, }); if (!signDetails) { diff --git a/ts/session/apis/snode_api/signature/groupSignature.ts b/ts/session/apis/snode_api/signature/groupSignature.ts index 0843bc8a8..48f415174 100644 --- a/ts/session/apis/snode_api/signature/groupSignature.ts +++ b/ts/session/apis/snode_api/signature/groupSignature.ts @@ -293,7 +293,6 @@ async function getGroupSignatureByHashesParams({ const signatureTimestamp = GetNetworkTime.now(); const sodium = await getSodiumRenderer(); - // N try { if (group.secretKey && !isEmpty(group.secretKey)) { const signature = sodium.crypto_sign_detached(message, group.secretKey); @@ -303,7 +302,7 @@ async function getGroupSignatureByHashesParams({ signature: signatureBase64, pubkey: group.pubkeyHex, messages: messagesHashes, - timestamp: signatureTimestamp, // TODO audric is this causing backend signature issues? + timestamp: signatureTimestamp, }; } diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index c327d5b65..1b8d435cc 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -50,7 +50,7 @@ import { } from '../apis/snode_api/signature/groupSignature'; import { SnodeSignature, SnodeSignatureResult } from '../apis/snode_api/signature/snodeSignatures'; import { SnodePool } from '../apis/snode_api/snodePool'; -import { WithMessagesHashes, WithRevokeSubRequest } from '../apis/snode_api/types'; +import { WithRevokeSubRequest } from '../apis/snode_api/types'; import { TTL_DEFAULT } from '../constants'; import { ConvoHub } from '../conversations'; import { MessageEncrypter } from '../crypto/MessageEncrypter'; @@ -184,6 +184,13 @@ async function sendSingleMessage({ ); } } else if (PubKey.is03Pubkey(destination)) { + const group = await UserGroupsWrapperActions.getGroup(destination); + if (!group) { + window.log.warn( + `sendSingleMessage: no such group found in wrapper: ${ed25519Str(destination)}` + ); + throw new Error('sendSingleMessage: no such group found in wrapper'); + } if (SnodeNamespace.isGroupConfigNamespace(encryptedAndWrapped.namespace)) { subRequests.push( new StoreGroupConfigOrMessageSubRequest({ @@ -192,6 +199,7 @@ async function sendSingleMessage({ ttlMs: overridenTtl, groupPk: destination, dbMessageIdentifier: encryptedAndWrapped.identifier || null, + ...group, }) ); } else if (encryptedAndWrapped.namespace === SnodeNamespaces.ClosedGroupMessages) { @@ -202,6 +210,7 @@ async function sendSingleMessage({ ttlMs: overridenTtl, groupPk: destination, dbMessageIdentifier: encryptedAndWrapped.identifier || null, + ...group, }) ); } else { @@ -338,38 +347,33 @@ async function signSubRequests( return signedRequests; } -async function sendMessagesDataToSnode( +async function sendMessagesDataToSnode( storeRequests: Array< | StoreGroupConfigOrMessageSubRequest | StoreUserConfigSubRequest | StoreUserMessageSubRequest | StoreLegacyGroupMessageSubRequest >, - asssociatedWith: PubkeyType | GroupPubkeyType, + asssociatedWith: T, { - messagesHashes: messagesToDelete, revokeSubRequest, unrevokeSubRequest, + deleteHashesSubRequest, deleteAllMessagesSubRequest, - }: WithMessagesHashes & - WithRevokeSubRequest & { - deleteAllMessagesSubRequest?: DeleteAllFromGroupMsgNodeSubRequest | null; - }, + }: WithRevokeSubRequest & { + deleteAllMessagesSubRequest?: DeleteAllFromGroupMsgNodeSubRequest | null; + deleteHashesSubRequest: + | (T extends PubkeyType + ? DeleteHashesFromUserNodeSubRequest + : DeleteHashesFromGroupNodeSubRequest) + | null; + }, method: MethodBatchType ): Promise { if (!asssociatedWith) { throw new Error('sendMessagesDataToSnode first subrequest pubkey needs to be set'); } - const deleteHashesSubRequest = !messagesToDelete.length - ? null - : PubKey.is05Pubkey(asssociatedWith) - ? new DeleteHashesFromUserNodeSubRequest({ messagesHashes: messagesToDelete }) - : new DeleteHashesFromGroupNodeSubRequest({ - messagesHashes: messagesToDelete, - groupPk: asssociatedWith, - }); - if (storeRequests.some(m => m.destination !== asssociatedWith)) { throw new Error( 'sendMessagesDataToSnode tried to send batchrequest containing subrequest not for the right destination' @@ -565,17 +569,21 @@ async function encryptMessagesAndWrap( * @param destination the pubkey we should deposit those message to * @returns the hashes of successful deposit */ -async function sendEncryptedDataToSnode({ +async function sendEncryptedDataToSnode({ destination, storeRequests, - messagesHashesToDelete, + deleteHashesSubRequest, revokeSubRequest, unrevokeSubRequest, deleteAllMessagesSubRequest, }: WithRevokeSubRequest & { storeRequests: Array; - destination: GroupPubkeyType | PubkeyType; - messagesHashesToDelete: Set | null; + destination: T; + deleteHashesSubRequest: + | (T extends PubkeyType + ? DeleteHashesFromUserNodeSubRequest + : DeleteHashesFromGroupNodeSubRequest) + | null; deleteAllMessagesSubRequest?: DeleteAllFromGroupMsgNodeSubRequest | null; }): Promise { try { @@ -585,7 +593,7 @@ async function sendEncryptedDataToSnode({ storeRequests, destination, { - messagesHashes: [...(messagesHashesToDelete || [])], + deleteHashesSubRequest, revokeSubRequest, unrevokeSubRequest, deleteAllMessagesSubRequest, diff --git a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts index 75e70bce2..ba14067f3 100644 --- a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts @@ -139,7 +139,6 @@ class GroupPendingRemovalsJob extends PersistedJob = updateMessages.map(updateMessage => { const wrapped = MessageSender.wrapContentIntoEnvelope( SignalService.Envelope.Type.SESSION_MESSAGE, @@ -122,13 +134,14 @@ async function storeGroupUpdateMessages({ namespace: m.namespace, ttlMs: m.ttl, dbMessageIdentifier: m.dbMessageIdentifier, + ...group, }); }); const result = await MessageSender.sendEncryptedDataToSnode({ storeRequests: [...updateMessagesRequests], destination: groupPk, - messagesHashesToDelete: null, + deleteHashesSubRequest: null, revokeSubRequest: null, unrevokeSubRequest: null, deleteAllMessagesSubRequest: null, @@ -172,6 +185,13 @@ async function pushChangesToGroupSwarmIfNeeded({ !unrevokeSubRequest && !deleteAllMessagesSubRequest ) { + window.log.debug(`pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: nothing to push`); + return RunJobResult.Success; + } + + const group = await UserGroupsWrapperActions.getGroup(groupPk); + if (!group) { + window.log.debug(`pushChangesToGroupSwarmIfNeeded: ${ed25519Str(groupPk)}: group not found`); return RunJobResult.Success; } @@ -208,25 +228,75 @@ async function pushChangesToGroupSwarmIfNeeded({ data: keysEncrypted[index], })); - const pendingConfigRequests = pendingConfigMsgs.map(m => { - return new StoreGroupConfigOrMessageSubRequest({ - encryptedData: m.data, - groupPk, - namespace: m.namespace, - ttlMs: m.ttl, - dbMessageIdentifier: null, // those are config messages only, they have no dbMessageIdentifier + 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 StoreGroupConfigOrMessageSubRequest({ + encryptedData: m.data, + groupPk, + namespace: m.namespace, + ttlMs: m.ttl, + dbMessageIdentifier: null, // those are config messages only, they have no dbMessageIdentifier + secretKey: group.secretKey, + authData: null, + }); }); - }); + } - const keysEncryptedRequests = keysEncryptedmessage.map(m => { - return new StoreGroupConfigOrMessageSubRequest({ - encryptedData: m.data, + if (keysEncryptedmessage.length) { + 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 = keysEncryptedmessage.map(m => { + return new StoreGroupConfigOrMessageSubRequest({ + encryptedData: m.data, + groupPk, + namespace: m.namespace, + ttlMs: m.ttl, + dbMessageIdentifier: null, // those are supplemental keys messages only, they have no dbMessageIdentifier + 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, - namespace: m.namespace, - ttlMs: m.ttl, - dbMessageIdentifier: null, // those are supplemental keys messages only, they have no dbMessageIdentifier + secretKey: group.secretKey, }); - }); + } if ( revokeSubRequest?.revokeTokenHex.length === 0 || @@ -240,7 +310,7 @@ async function pushChangesToGroupSwarmIfNeeded({ const result = await MessageSender.sendEncryptedDataToSnode({ storeRequests: [...pendingConfigRequests, ...keysEncryptedRequests], destination: groupPk, - messagesHashesToDelete: allOldHashes, + deleteHashesSubRequest, revokeSubRequest, unrevokeSubRequest, deleteAllMessagesSubRequest, diff --git a/ts/session/utils/job_runners/jobs/UserSyncJob.ts b/ts/session/utils/job_runners/jobs/UserSyncJob.ts index 50d5fb378..86fef80cb 100644 --- a/ts/session/utils/job_runners/jobs/UserSyncJob.ts +++ b/ts/session/utils/job_runners/jobs/UserSyncJob.ts @@ -8,7 +8,10 @@ import { ConfigDumpData } from '../../../../data/configDump/configDump'; import { UserSyncJobDone } from '../../../../shims/events'; import { isSignInByLinking } from '../../../../util/storage'; import { GenericWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; -import { StoreUserConfigSubRequest } from '../../../apis/snode_api/SnodeRequestTypes'; +import { + DeleteHashesFromUserNodeSubRequest, + StoreUserConfigSubRequest, +} from '../../../apis/snode_api/SnodeRequestTypes'; import { TTL_DEFAULT } from '../../../constants'; import { ConvoHub } from '../../../conversations'; import { MessageSender } from '../../../sending/MessageSender'; @@ -109,7 +112,9 @@ async function pushChangesToUserSwarmIfNeeded() { const result = await MessageSender.sendEncryptedDataToSnode({ storeRequests, destination: us, - messagesHashesToDelete: changesToPush.allOldHashes, + deleteHashesSubRequest: new DeleteHashesFromUserNodeSubRequest({ + messagesHashes: [...changesToPush.allOldHashes], + }), revokeSubRequest: null, unrevokeSubRequest: null, });