diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index c3e65977b..5ec3a7c66 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -98,6 +98,26 @@ jobs: # DEBUG: 'mock:benchmarks' ARTIFACTS_DIR: artifacts/group-send + - name: Run large group send benchmarks with blocks + run: | + set -o pipefail + rm -rf /tmp/mock + xvfb-run --auto-servernum node \ + ts/test-mock/benchmarks/group_send_bench.js | \ + tee benchmark-group-send.log + timeout-minutes: 10 + env: + NODE_ENV: production + GROUP_SIZE: 500 + CONTACT_COUNT: 500 + BLOCKED_COUNT: 10 + DISCARD_COUNT: 2 + RUN_COUNT: 50 + CONVERSATION_SIZE: 500 + ELECTRON_ENABLE_STACK_DUMPING: on + # DEBUG: 'mock:benchmarks' + ARTIFACTS_DIR: artifacts/group-send + - name: Run large group send benchmarks with delivery receipts run: | set -o pipefail diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b78e4b20e..22654affd 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -784,6 +784,14 @@ "messageformat": "Submit log", "description": "Label for the decryption error toast button" }, + "icu:Toast__ActionLabel--SubmitLog": { + "messageformat": "Submit log", + "description": "Label for the submit log button in decryption errors or other important error types" + }, + "icu:Toast--FailedToSendWithEndorsements": { + "messageformat": "Failed to send message with endorsements", + "description": "An error popup when we attempted and failed to send a message using endorsements, only for internal users." + }, "icu:cannotSelectPhotosAndVideosAlongWithFiles": { "messageformat": "You can't select photos and videos along with files.", "description": "An error popup when the user has attempted to add an attachment" diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index e3082bae8..bb97e3c55 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -96,6 +96,8 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.FailedToFetchPhoneNumber }; case ToastType.FailedToFetchUsername: return { toastType: ToastType.FailedToFetchUsername }; + case ToastType.FailedToSendWithEndorsements: + return { toastType: ToastType.FailedToSendWithEndorsements }; case ToastType.FileSaved: return { toastType: ToastType.FileSaved, diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index f44d901ed..eff0adb80 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -285,6 +285,20 @@ export function renderToast({ ); } + if (toastType === ToastType.FailedToSendWithEndorsements) { + return ( + + {i18n('icu:Toast--FailedToSendWithEndorsements')} + + ); + } + if (toastType === ToastType.FileSaved) { return ( diff --git a/ts/groups.ts b/ts/groups.ts index 7e72a5cd6..76e50614f 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -96,7 +96,10 @@ import { SeenStatus } from './MessageSeenStatus'; import { incrementMessageCounter } from './util/incrementMessageCounter'; import { sleep } from './util/sleep'; import { groupInvitesRoute } from './util/signalRoutes'; -import { decodeGroupSendEndorsementResponse } from './util/groupSendEndorsements'; +import { + decodeGroupSendEndorsementResponse, + isValidGroupSendEndorsementsExpiration, +} from './util/groupSendEndorsements'; type AccessRequiredEnum = Proto.AccessControl.AccessRequired; @@ -3981,6 +3984,16 @@ async function updateGroupViaLogs({ let cachedEndorsementsExpiration = await DataReader.getGroupSendCombinedEndorsementExpiration(groupId); + if ( + cachedEndorsementsExpiration != null && + !isValidGroupSendEndorsementsExpiration(cachedEndorsementsExpiration) + ) { + log.info( + `updateGroupViaLogs/${logId}: Group had invalid endorsements expiration (${cachedEndorsementsExpiration}), fetching new endorsements` + ); + cachedEndorsementsExpiration = null; + } + let response: GroupLogResponseType; let groupSendEndorsementResponse: Uint8Array | null = null; const changes: Array = []; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 39383420b..7112527f0 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -374,6 +374,10 @@ export type ConversationAttributesType = { lastProfile?: ConversationLastProfileType; needsTitleTransition?: boolean; quotedMessageId?: string | null; + /** + * TODO: Rename this key to be specific to the accessKey on the conversation + * It's not used for group endorsements. + */ sealedSender?: unknown; sentMessageCount?: number; sharedGroupNames?: ReadonlyArray; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 41d941ccb..7c16507b6 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -37,7 +37,10 @@ import type { CallLinkType, } from '../types/CallLink'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; -import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements'; +import type { + GroupSendEndorsementsData, + GroupSendMemberEndorsementRecord, +} from '../types/GroupSendEndorsements'; import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; @@ -481,6 +484,13 @@ type ReadableInterface = { ) => Array; getGroupSendCombinedEndorsementExpiration: (groupId: string) => number | null; + getGroupSendEndorsementsData: ( + groupId: string + ) => GroupSendEndorsementsData | null; + getGroupSendMemberEndorsement: ( + groupId: string, + memberAci: AciString + ) => GroupSendMemberEndorsementRecord | null; getMessageCount: (conversationId?: string) => number; getStoryCount: (conversationId: string) => number; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index fa6bbd708..52c5050dd 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -191,7 +191,9 @@ import { replaceAllEndorsementsForGroup, deleteAllEndorsementsForGroup, getGroupSendCombinedEndorsementExpiration, -} from './server/groupEndorsements'; + getGroupSendEndorsementsData, + getGroupSendMemberEndorsement, +} from './server/groupSendEndorsements'; import { attachmentDownloadJobSchema, type AttachmentDownloadJobType, @@ -265,6 +267,8 @@ export const DataReader: ServerReadableInterface = { getAllGroupsInvolvingServiceId, getGroupSendCombinedEndorsementExpiration, + getGroupSendEndorsementsData, + getGroupSendMemberEndorsement, searchMessages, diff --git a/ts/sql/mainWorker.ts b/ts/sql/mainWorker.ts index bdb179cce..916f99c27 100644 --- a/ts/sql/mainWorker.ts +++ b/ts/sql/mainWorker.ts @@ -154,7 +154,7 @@ port.on('message', ({ seq, request }: WrappedWorkerRequest) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const method = (DataInterface as any)[request.method]; if (typeof method !== 'function') { - throw new Error(`Invalid sql method: ${method}`); + throw new Error(`Invalid sql method: ${request.method} ${method}`); } const start = performance.now(); diff --git a/ts/sql/server/groupEndorsements.ts b/ts/sql/server/groupEndorsements.ts deleted file mode 100644 index 51126c7e2..000000000 --- a/ts/sql/server/groupEndorsements.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2024 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { - GroupSendCombinedEndorsementRecord, - GroupSendEndorsementsData, - GroupSendMemberEndorsementRecord, -} from '../../types/GroupSendEndorsements'; -import { groupSendEndorsementExpirationSchema } from '../../types/GroupSendEndorsements'; -import { prepare } from '../Server'; -import type { ReadableDB, WritableDB } from '../Interface'; -import { sql } from '../util'; - -/** - * We don't need to store more than one endorsement per group or per member. - */ -export function replaceAllEndorsementsForGroup( - db: WritableDB, - data: GroupSendEndorsementsData -): void { - db.transaction(() => { - const { combinedEndorsement, memberEndorsements } = data; - _replaceCombinedEndorsement(db, combinedEndorsement); - _replaceMemberEndorsements(db, memberEndorsements); - })(); -} - -function _replaceCombinedEndorsement( - db: WritableDB, - combinedEndorsement: GroupSendCombinedEndorsementRecord -): void { - const { groupId, expiration, endorsement } = combinedEndorsement; - const [insertCombined, insertCombinedParams] = sql` - INSERT OR REPLACE INTO groupSendCombinedEndorsement - (groupId, expiration, endorsement) - VALUES (${groupId}, ${expiration}, ${endorsement}); - `; - prepare>(db, insertCombined).run(insertCombinedParams); -} - -function _replaceMemberEndorsements( - db: WritableDB, - memberEndorsements: ReadonlyArray -) { - for (const memberEndorsement of memberEndorsements) { - const { groupId, memberAci, expiration, endorsement } = memberEndorsement; - const [replaceMember, replaceMemberParams] = sql` - INSERT OR REPLACE INTO groupSendMemberEndorsement - (groupId, memberAci, expiration, endorsement) - VALUES (${groupId}, ${memberAci}, ${expiration}, ${endorsement}); - `; - prepare>(db, replaceMember).run(replaceMemberParams); - } -} - -export function deleteAllEndorsementsForGroup( - db: WritableDB, - groupId: string -): void { - db.transaction(() => { - const [deleteCombined, deleteCombinedParams] = sql` - DELETE FROM groupSendCombinedEndorsement - WHERE groupId = ${groupId}; - `; - const [deleteMembers, deleteMembersParams] = sql` - DELETE FROM groupSendMemberEndorsement - WHERE groupId = ${groupId}; - `; - prepare>(db, deleteCombined).run(deleteCombinedParams); - prepare>(db, deleteMembers).run(deleteMembersParams); - })(); -} - -export function getGroupSendCombinedEndorsementExpiration( - db: ReadableDB, - groupId: string -): number | null { - const [selectGroup, selectGroupParams] = sql` - SELECT expiration FROM groupSendCombinedEndorsement - WHERE groupId = ${groupId}; - `; - const value = prepare>(db, selectGroup) - .pluck() - .get(selectGroupParams); - if (value == null) { - return null; - } - return groupSendEndorsementExpirationSchema.parse(value); -} diff --git a/ts/sql/server/groupSendEndorsements.ts b/ts/sql/server/groupSendEndorsements.ts new file mode 100644 index 000000000..7b556117d --- /dev/null +++ b/ts/sql/server/groupSendEndorsements.ts @@ -0,0 +1,172 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + GroupSendCombinedEndorsementRecord, + GroupSendEndorsementsData, + GroupSendMemberEndorsementRecord, +} from '../../types/GroupSendEndorsements'; +import { + groupSendEndorsementExpirationSchema, + groupSendCombinedEndorsementSchema, + groupSendMemberEndorsementSchema, + groupSendEndorsementsDataSchema, +} from '../../types/GroupSendEndorsements'; +import { prepare } from '../Server'; +import type { ReadableDB, WritableDB } from '../Interface'; +import { sql } from '../util'; +import type { AciString } from '../../types/ServiceId'; +import { strictAssert } from '../../util/assert'; + +/** + * We don't need to store more than one endorsement per group or per member. + */ +export function replaceAllEndorsementsForGroup( + db: WritableDB, + data: GroupSendEndorsementsData +): void { + db.transaction(() => { + const { combinedEndorsement, memberEndorsements } = data; + const { groupId } = combinedEndorsement; + _deleteAllEndorsementsForGroup(db, groupId); + _replaceCombinedEndorsement(db, combinedEndorsement); + _replaceMemberEndorsements(db, memberEndorsements); + })(); +} + +function _deleteAllEndorsementsForGroup(db: WritableDB, groupId: string): void { + const [deleteCombined, deleteCombinedParams] = sql` + DELETE FROM groupSendCombinedEndorsement + WHERE groupId = ${groupId}; + `; + const [deleteMembers, deleteMembersParams] = sql` + DELETE FROM groupSendMemberEndorsement + WHERE groupId IS ${groupId}; + `; + prepare>(db, deleteCombined).run(deleteCombinedParams); + prepare>(db, deleteMembers).run(deleteMembersParams); +} + +function _replaceCombinedEndorsement( + db: WritableDB, + combinedEndorsement: GroupSendCombinedEndorsementRecord +): void { + const { groupId, expiration, endorsement } = combinedEndorsement; + const [insertCombined, insertCombinedParams] = sql` + INSERT OR REPLACE INTO groupSendCombinedEndorsement + (groupId, expiration, endorsement) + VALUES (${groupId}, ${expiration}, ${endorsement}); + `; + const result = prepare>(db, insertCombined).run( + insertCombinedParams + ); + strictAssert( + result.changes === 1, + 'Must update groupSendCombinedEndorsement' + ); +} + +function _replaceMemberEndorsements( + db: WritableDB, + memberEndorsements: ReadonlyArray +) { + for (const memberEndorsement of memberEndorsements) { + const { groupId, memberAci, expiration, endorsement } = memberEndorsement; + const [replaceMember, replaceMemberParams] = sql` + INSERT OR REPLACE INTO groupSendMemberEndorsement + (groupId, memberAci, expiration, endorsement) + VALUES (${groupId}, ${memberAci}, ${expiration}, ${endorsement}); + `; + const result = prepare>(db, replaceMember).run( + replaceMemberParams + ); + strictAssert( + result.changes === 1, + 'Must update groupSendMemberEndorsement' + ); + } +} + +export function deleteAllEndorsementsForGroup( + db: WritableDB, + groupId: string +): void { + db.transaction(() => { + _deleteAllEndorsementsForGroup(db, groupId); + })(); +} + +export function getGroupSendCombinedEndorsementExpiration( + db: ReadableDB, + groupId: string +): number | null { + const [selectGroup, selectGroupParams] = sql` + SELECT expiration FROM groupSendCombinedEndorsement + WHERE groupId IS ${groupId}; + `; + const value = prepare>(db, selectGroup) + .pluck() + .get(selectGroupParams); + if (value == null) { + return null; + } + return groupSendEndorsementExpirationSchema.parse(value); +} + +export function getGroupSendEndorsementsData( + db: ReadableDB, + groupId: string +): GroupSendEndorsementsData | null { + return db.transaction(() => { + const [selectCombinedEndorsement, selectCombinedEndorsementParams] = sql` + SELECT * FROM groupSendCombinedEndorsement + WHERE groupId IS ${groupId} + `; + + const [selectMemberEndorsements, selectMemberEndorsementsParams] = sql` + SELECT * FROM groupSendMemberEndorsement + WHERE groupId IS ${groupId} + `; + + const combinedEndorsement = groupSendCombinedEndorsementSchema + .optional() + .parse( + prepare>(db, selectCombinedEndorsement).get( + selectCombinedEndorsementParams + ) + ); + + if (combinedEndorsement == null) { + return null; + } + + const memberEndorsements = prepare>( + db, + selectMemberEndorsements + ).all(selectMemberEndorsementsParams); + + return groupSendEndorsementsDataSchema.parse({ + combinedEndorsement, + memberEndorsements, + }); + })(); +} + +export function getGroupSendMemberEndorsement( + db: ReadableDB, + groupId: string, + memberAci: AciString +): GroupSendMemberEndorsementRecord | null { + const [selectMemberEndorsements, selectMemberEndorsementsParams] = sql` + SELECT * FROM groupSendMemberEndorsement + WHERE groupId IS ${groupId} + AND memberAci IS ${memberAci} + `; + const row = prepare>(db, selectMemberEndorsements).get( + selectMemberEndorsementsParams + ); + if (row == null) { + return null; + } + return groupSendMemberEndorsementSchema.parse(row); +} diff --git a/ts/test-mock/benchmarks/fixtures.ts b/ts/test-mock/benchmarks/fixtures.ts index e0d85abdd..b64df6e88 100644 --- a/ts/test-mock/benchmarks/fixtures.ts +++ b/ts/test-mock/benchmarks/fixtures.ts @@ -35,6 +35,10 @@ export const DISCARD_COUNT = process.env.DISCARD_COUNT ? parseInt(process.env.DISCARD_COUNT, 10) : 5; +export const BLOCKED_COUNT = process.env.BLOCKED_COUNT + ? parseInt(process.env.BLOCKED_COUNT, 10) + : 0; + // Can happen if electron exits prematurely process.on('unhandledRejection', reason => { console.error('Unhandled rejection:'); diff --git a/ts/test-mock/benchmarks/group_send_bench.ts b/ts/test-mock/benchmarks/group_send_bench.ts index 79ab785a8..b10179628 100644 --- a/ts/test-mock/benchmarks/group_send_bench.ts +++ b/ts/test-mock/benchmarks/group_send_bench.ts @@ -9,7 +9,6 @@ import { EnvelopeType, ReceiptType, } from '@signalapp/mock-server'; - import { Bootstrap, debug, @@ -18,6 +17,7 @@ import { CONVERSATION_SIZE, DISCARD_COUNT, GROUP_DELIVERY_RECEIPTS, + BLOCKED_COUNT, } from './fixtures'; import { stats } from '../../util/benchmark/stats'; import { sleep } from '../../util/sleep'; @@ -72,13 +72,31 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { ); } + assert.ok( + BLOCKED_COUNT < members.length - 1, + 'Must block fewer members than are in the group' + ); + + const unblockedMembers = members.slice(0, members.length - BLOCKED_COUNT); + const blockedMembers = members.slice(members.length - BLOCKED_COUNT); + + if (blockedMembers.length > 0) { + let state = await phone.expectStorageState('blocking'); + + for (const member of blockedMembers) { + state = state.addContact(member, { + blocked: true, + }); + } + await phone.setStorageState(state); + } + // Fill group for (let i = 0; i < CONVERSATION_SIZE; i += 1) { - const contact = members[i % members.length]; + const contact = unblockedMembers[i % unblockedMembers.length]; const messageTimestamp = bootstrap.getTimestamp(); const isLast = i === CONVERSATION_SIZE - 1; - messages.push( await contact.encryptText( desktop, @@ -90,17 +108,20 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { } ) ); - messages.push( - await phone.encryptSyncRead(desktop, { - timestamp: bootstrap.getTimestamp(), - messages: [ - { - senderAci: contact.device.aci, - timestamp: messageTimestamp, - }, - ], - }) - ); + // Last message should trigger an unread indicator + if (!isLast) { + messages.push( + await phone.encryptSyncRead(desktop, { + timestamp: bootstrap.getTimestamp(), + messages: [ + { + senderAci: contact.device.aci, + timestamp: messageTimestamp, + }, + ], + }) + ); + } } debug('encrypted'); @@ -114,13 +135,30 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { const item = leftPane .locator( - '.module-conversation-list__item--contact-or-conversation' + - `>> text="${GROUP_NAME}"` + `.module-conversation-list__item--contact-or-conversation[data-testid="${group.id}"]` ) .first(); + + // Wait for unread indicator to give desktop time to process messages without + // the timeline open + await item + .locator( + '.module-conversation-list__item--contact-or-conversation__content' + ) + .locator( + '.module-conversation-list__item--contact-or-conversation__unread-indicator' + ) + .first() + .waitFor(); + await item.click(); } + debug('scrolling to bottom of timeline'); + await window + .locator('.module-timeline__messages__at-bottom-detector') + .scrollIntoViewIfNeeded(); + debug('finding message in timeline'); { const item = window diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 85ae70a6e..c1f888c96 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -281,20 +281,21 @@ export default class OutgoingMessage { async getKeysForServiceId( serviceId: ServiceIdString, - updateDevices?: Array + updateDevices: Array | null ): Promise { const { sendMetadata } = this; const info = sendMetadata && sendMetadata[serviceId] ? sendMetadata[serviceId] - : { accessKey: undefined }; + : { accessKey: null }; const { accessKey } = info; const { accessKeyFailed } = await getKeysForServiceId( serviceId, this.server, - updateDevices, - accessKey + updateDevices ?? null, + accessKey, + null ); if (accessKeyFailed && !this.failoverServiceIds.includes(serviceId)) { this.failoverServiceIds.push(serviceId); @@ -607,8 +608,8 @@ export default class OutgoingMessage { return p.then(async () => { const resetDevices = error.code === 410 - ? response.staleDevices - : response.missingDevices; + ? (response.staleDevices ?? null) + : (response.missingDevices ?? null); return this.getKeysForServiceId(serviceId, resetDevices).then( // We continue to retry as long as the error code was 409; the assumption is // that we'll request new device info and the next request will succeed. @@ -677,7 +678,7 @@ export default class OutgoingMessage { serviceId, }); if (deviceIds.length === 0) { - await this.getKeysForServiceId(serviceId); + await this.getKeysForServiceId(serviceId, null); } await this.reloadDevicesAndSend(serviceId, true)(); } catch (error) { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index f5f7ecb10..d1a2504dc 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -72,6 +72,7 @@ import { SECOND } from '../util/durations'; import { safeParseNumber } from '../util/numbers'; import { isStagingServer } from '../util/isStagingServer'; import type { IWebSocketResource } from './WebsocketResources'; +import type { GroupSendToken } from '../types/GroupSendEndorsements'; // Note: this will break some code that expects to be able to use err.response when a // web request fails, because it will force it to text. But it is very useful for @@ -184,10 +185,12 @@ type PromiseAjaxOptionsType = { | { unauthenticated?: false; accessKey?: string; + groupSendToken?: GroupSendToken; } | { unauthenticated: true; accessKey: undefined | string; + groupSendToken: undefined | GroupSendToken; } ); @@ -330,11 +333,13 @@ async function _promiseAjax( fetchOptions.headers['Content-Length'] = contentLength.toString(); } - const { accessKey, basicAuth, unauthenticated } = options; + const { accessKey, basicAuth, groupSendToken, unauthenticated } = options; if (basicAuth) { fetchOptions.headers.Authorization = `Basic ${basicAuth}`; } else if (unauthenticated) { - if (accessKey) { + if (groupSendToken != null) { + fetchOptions.headers['Group-Send-Token'] = Bytes.toBase64(groupSendToken); + } else if (accessKey != null) { // Access key is already a Base64 string fetchOptions.headers['Unidentified-Access-Key'] = accessKey; } @@ -708,10 +713,12 @@ type AjaxOptionsType = { | { unauthenticated?: false; accessKey?: string; + groupSendToken?: GroupSendToken; } | { unauthenticated: true; accessKey: undefined | string; + groupSendToken: undefined | GroupSendToken; } ); @@ -1279,7 +1286,7 @@ export type WebAPIType = { getKeysForServiceIdUnauth: ( serviceId: ServiceIdString, deviceId?: number, - options?: { accessKey?: string } + options?: { accessKey?: string; groupSendToken?: GroupSendToken } ) => Promise; getMyKeyCounts: (serviceIdKind: ServiceIdKind) => Promise; getOnboardingStoryManifest: () => Promise<{ @@ -1398,7 +1405,8 @@ export type WebAPIType = { ) => Promise; sendWithSenderKey: ( payload: Uint8Array, - accessKeys: Uint8Array, + accessKeys: Uint8Array | undefined, + groupSendToken: GroupSendToken | undefined, timestamp: number, options: { online?: boolean; @@ -1868,6 +1876,7 @@ export function initialize({ version, unauthenticated: param.unauthenticated, accessKey: param.accessKey, + groupSendToken: param.groupSendToken, abortSignal: param.abortSignal, }; @@ -2204,6 +2213,7 @@ export function initialize({ redactUrl: _createRedactor(hashBase64), unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }) ); } @@ -2246,6 +2256,7 @@ export function initialize({ responseType: 'json', unauthenticated: true, accessKey, + groupSendToken: undefined, redactUrl: _createRedactor( serviceId, profileKeyVersion, @@ -2418,6 +2429,7 @@ export function initialize({ responseType: 'json', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }) ); } @@ -2454,6 +2466,7 @@ export function initialize({ }, unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }) ); @@ -2469,6 +2482,7 @@ export function initialize({ }, unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }) ); @@ -2491,6 +2505,7 @@ export function initialize({ }, unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }) ); @@ -2506,6 +2521,7 @@ export function initialize({ urlParameters: `/${serviceId}`, unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }); return true; } catch (error) { @@ -2595,6 +2611,7 @@ export function initialize({ }, unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, }) ); @@ -2801,6 +2818,7 @@ export function initialize({ httpType: 'GET', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, responseType: 'json', }); @@ -2836,6 +2854,7 @@ export function initialize({ httpType: 'GET', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, responseType: 'json', }); @@ -2887,6 +2906,7 @@ export function initialize({ httpType: 'GET', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, responseType: 'json', }); @@ -2900,6 +2920,7 @@ export function initialize({ httpType: 'POST', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, }); } @@ -2931,6 +2952,7 @@ export function initialize({ httpType: 'GET', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, urlParameters: `?cdn=${cdn}`, responseType: 'json', @@ -2962,6 +2984,7 @@ export function initialize({ httpType: 'PUT', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, jsonData: { backupIdPublicKey: Bytes.toBase64(backupIdPublicKey), @@ -2978,6 +3001,7 @@ export function initialize({ httpType: 'PUT', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, responseType: 'json', jsonData: { @@ -3018,6 +3042,7 @@ export function initialize({ httpType: 'POST', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, jsonData: { mediaToDelete: mediaToDelete.map(({ cdn, mediaId }) => { @@ -3047,6 +3072,7 @@ export function initialize({ httpType: 'GET', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, headers, responseType: 'json', urlParameters: `?${params.join('&')}`, @@ -3194,7 +3220,10 @@ export function initialize({ async function getKeysForServiceIdUnauth( serviceId: ServiceIdString, deviceId?: number, - { accessKey }: { accessKey?: string } = {} + { + accessKey, + groupSendToken, + }: { accessKey?: string; groupSendToken?: GroupSendToken } = {} ) { const keys = (await _ajax({ call: 'keys', @@ -3204,6 +3233,7 @@ export function initialize({ validateResponse: { identityKey: 'string', devices: 'object' }, unauthenticated: true, accessKey, + groupSendToken, })) as ServerKeyResponseType; return handleKeys(keys); } @@ -3239,6 +3269,7 @@ export function initialize({ responseType: 'json', unauthenticated: true, accessKey, + groupSendToken: undefined, }); } @@ -3274,7 +3305,8 @@ export function initialize({ async function sendWithSenderKey( data: Uint8Array, - accessKeys: Uint8Array, + accessKeys: Uint8Array | undefined, + groupSendToken: GroupSendToken | undefined, timestamp: number, { online, @@ -3298,7 +3330,8 @@ export function initialize({ urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`, responseType: 'json', unauthenticated: true, - accessKey: Bytes.toBase64(accessKeys), + accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined, + groupSendToken, }); const parseResult = multiRecipient200ResponseSchema.safeParse(response); if (parseResult.success) { @@ -4240,6 +4273,7 @@ export function initialize({ responseType: 'json', unauthenticated: true, accessKey: undefined, + groupSendToken: undefined, redactUrl: _createRedactor(formattedId), }); diff --git a/ts/textsecure/getKeysForServiceId.ts b/ts/textsecure/getKeysForServiceId.ts index 638dc5d32..48121b4ba 100644 --- a/ts/textsecure/getKeysForServiceId.ts +++ b/ts/textsecure/getKeysForServiceId.ts @@ -23,18 +23,22 @@ import type { ServiceIdString } from '../types/ServiceId'; import type { ServerKeysType, WebAPIType } from './WebAPI'; import * as log from '../logging/log'; import { isRecord } from '../util/isRecord'; +import type { GroupSendToken } from '../types/GroupSendEndorsements'; +import { onFailedToSendWithEndorsements } from '../util/groupSendEndorsements'; export async function getKeysForServiceId( serviceId: ServiceIdString, server: WebAPIType, - devicesToUpdate?: Array, - accessKey?: string + devicesToUpdate: Array | null, + accessKey: string | null, + groupSendToken: GroupSendToken | null ): Promise<{ accessKeyFailed?: boolean }> { try { const { keys, accessKeyFailed } = await getServerKeys( serviceId, server, - accessKey + accessKey, + groupSendToken ); await handleServerKeys(serviceId, keys, devicesToUpdate); @@ -53,44 +57,67 @@ export async function getKeysForServiceId( } } +function isUnauthorizedError(error: unknown) { + return ( + isRecord(error) && + typeof error.code === 'number' && + (error.code === 401 || error.code === 403) + ); +} + async function getServerKeys( serviceId: ServiceIdString, server: WebAPIType, - accessKey?: string -): Promise<{ accessKeyFailed?: boolean; keys: ServerKeysType }> { - try { - if (!accessKey) { - return { - keys: await server.getKeysForServiceId(serviceId), - }; - } + accessKey: string | null, + groupSendToken: GroupSendToken | null +): Promise<{ accessKeyFailed: boolean; keys: ServerKeysType }> { + // Return true only when attempted with access key + let accessKeyFailed = false; - return { - keys: await server.getKeysForServiceIdUnauth(serviceId, undefined, { - accessKey, - }), - }; - } catch (error: unknown) { - if ( - accessKey && - isRecord(error) && - typeof error.code === 'number' && - (error.code === 401 || error.code === 403) - ) { - return { - accessKeyFailed: true, - keys: await server.getKeysForServiceId(serviceId), - }; + if (accessKey != null) { + // Try the access key first + try { + const keys = await server.getKeysForServiceIdUnauth( + serviceId, + undefined, + { accessKey } + ); + return { keys, accessKeyFailed }; + } catch (error) { + accessKeyFailed = true; + if (!isUnauthorizedError(error)) { + throw error; + } } - - throw error; } + + if (groupSendToken != null) { + try { + const keys = await server.getKeysForServiceIdUnauth( + serviceId, + undefined, + { groupSendToken } + ); + return { keys, accessKeyFailed }; + } catch (error) { + if (!isUnauthorizedError(error)) { + throw error; + } else { + onFailedToSendWithEndorsements(error); + } + } + } + + return { + keys: await server.getKeysForServiceId(serviceId), + accessKeyFailed, + }; } async function handleServerKeys( serviceId: ServiceIdString, response: ServerKeysType, - devicesToUpdate?: Array + devicesToUpdate: Array | null ): Promise { const ourAci = window.textsecure.storage.user.getCheckedAci(); const sessionStore = new Sessions({ ourServiceId: ourAci }); @@ -100,10 +127,7 @@ async function handleServerKeys( response.devices.map(async device => { const { deviceId, registrationId, pqPreKey, preKey, signedPreKey } = device; - if ( - devicesToUpdate !== undefined && - !devicesToUpdate.includes(deviceId) - ) { + if (devicesToUpdate != null && !devicesToUpdate.includes(deviceId)) { return; } diff --git a/ts/types/GroupSendEndorsements.ts b/ts/types/GroupSendEndorsements.ts index 9b67c8c54..271f4d1cd 100644 --- a/ts/types/GroupSendEndorsements.ts +++ b/ts/types/GroupSendEndorsements.ts @@ -73,7 +73,26 @@ export const groupSendMemberEndorsementSchema = z.object({ endorsement: groupSendEndorsementSchema, }); -export const groupSendEndorsementsDataSchema = z.object({ - combinedEndorsement: groupSendCombinedEndorsementSchema, - memberEndorsements: z.array(groupSendMemberEndorsementSchema).min(1), -}); +export const groupSendEndorsementsDataSchema = z + .object({ + combinedEndorsement: groupSendCombinedEndorsementSchema, + memberEndorsements: z.array(groupSendMemberEndorsementSchema).min(1), + }) + .refine(data => { + return data.memberEndorsements.every(memberEndorsement => { + return ( + memberEndorsement.groupId === data.combinedEndorsement.groupId && + memberEndorsement.expiration === data.combinedEndorsement.expiration + ); + }); + }); + +export const groupSendTokenSchema = z + .instanceof(Uint8Array) + .brand('GroupSendToken'); + +export type GroupSendToken = z.infer; + +export function toGroupSendToken(token: Uint8Array): GroupSendToken { + return groupSendTokenSchema.parse(token); +} diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index f02f36bf3..2387ea07c 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -33,6 +33,7 @@ export enum ToastType { FailedToDeleteUsername = 'FailedToDeleteUsername', FailedToFetchPhoneNumber = 'FailedToFetchPhoneNumber', FailedToFetchUsername = 'FailedToFetchUsername', + FailedToSendWithEndorsements = 'FailedToSendWithEndorsements', FileSaved = 'FileSaved', FileSize = 'FileSize', GroupLinkCopied = 'GroupLinkCopied', @@ -105,6 +106,7 @@ export type AnyToast = | { toastType: ToastType.FailedToDeleteUsername } | { toastType: ToastType.FailedToFetchPhoneNumber } | { toastType: ToastType.FailedToFetchUsername } + | { toastType: ToastType.FailedToSendWithEndorsements } | { toastType: ToastType.FileSaved; parameters: { fullPath: string } } | { toastType: ToastType.FileSize; diff --git a/ts/util/groupSendEndorsements.ts b/ts/util/groupSendEndorsements.ts index a8af17def..ba933ab8e 100644 --- a/ts/util/groupSendEndorsements.ts +++ b/ts/util/groupSendEndorsements.ts @@ -1,19 +1,30 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { Aci } from '@signalapp/libsignal-client'; +import type { + GroupSendCombinedEndorsementRecord, + GroupSendMemberEndorsementRecord, + GroupSendToken, +} from '../types/GroupSendEndorsements'; import { groupSendEndorsementsDataSchema, + toGroupSendToken, type GroupSendEndorsementsData, } from '../types/GroupSendEndorsements'; -import { strictAssert } from './assert'; +import { assertDev, strictAssert } from './assert'; import { GroupSecretParams, + GroupSendEndorsement, GroupSendEndorsementsResponse, ServerPublicParams, } from './zkgroup'; +import type { AciString, ServiceIdString } from '../types/ServiceId'; import { fromAciObject } from '../types/ServiceId'; import * as log from '../logging/log'; import type { GroupV2MemberType } from '../model-types'; +import { DurationInSeconds } from './durations'; +import { ToastType } from '../types/Toast'; +import * as Errors from '../types/errors'; export function decodeGroupSendEndorsementResponse({ groupId, @@ -42,7 +53,7 @@ export function decodeGroupSendEndorsementResponse({ Buffer.from(groupSendEndorsementResponse) ); - const expiration = response.getExpiration().getTime(); + const expiration = response.getExpiration().getTime() / 1000; const localUser = Aci.parseFromServiceIdString( window.textsecure.storage.user.getCheckedAci() @@ -78,7 +89,7 @@ export function decodeGroupSendEndorsementResponse({ `decodeGroupSendEndorsementResponse: Received endorsements (group: ${idForLogging}, expiration: ${expiration}, members: ${groupMembers.length})` ); - const groupEndorsementData: GroupSendEndorsementsData = { + const groupEndorsementsData: GroupSendEndorsementsData = { combinedEndorsement: { groupId, expiration, @@ -99,5 +110,149 @@ export function decodeGroupSendEndorsementResponse({ }), }; - return groupSendEndorsementsDataSchema.parse(groupEndorsementData); + return groupSendEndorsementsDataSchema.parse(groupEndorsementsData); +} + +const TWO_DAYS = DurationInSeconds.fromDays(2); +const TWO_HOURS = DurationInSeconds.fromHours(2); + +export function isValidGroupSendEndorsementsExpiration( + expiration: number +): boolean { + const expSeconds = DurationInSeconds.fromMillis(expiration); + const nowSeconds = DurationInSeconds.fromMillis(Date.now()); + const distance = Math.trunc(expSeconds - nowSeconds); + return distance <= TWO_DAYS && distance > TWO_HOURS; +} + +export class GroupSendEndorsementState { + #combinedEndorsement: GroupSendCombinedEndorsementRecord; + #memberEndorsements = new Map< + ServiceIdString, + GroupSendMemberEndorsementRecord + >(); + #memberEndorsementsAcis = new Set(); + #groupSecretParamsBase64: string; + #endorsementCache = new WeakMap(); + + constructor( + data: GroupSendEndorsementsData, + groupSecretParamsBase64: string + ) { + this.#combinedEndorsement = data.combinedEndorsement; + for (const endorsement of data.memberEndorsements) { + this.#memberEndorsements.set(endorsement.memberAci, endorsement); + this.#memberEndorsementsAcis.add(endorsement.memberAci); + } + this.#groupSecretParamsBase64 = groupSecretParamsBase64; + } + + isSafeExpirationRange(): boolean { + return isValidGroupSendEndorsementsExpiration( + this.getExpiration().getTime() + ); + } + + getExpiration(): Date { + return new Date(this.#combinedEndorsement.expiration * 1000); + } + + hasMember(serviceId: ServiceIdString): boolean { + return this.#memberEndorsements.has(serviceId); + } + + #toEndorsement(contents: Uint8Array) { + let endorsement = this.#endorsementCache.get(contents); + if (endorsement == null) { + endorsement = new GroupSendEndorsement(Buffer.from(contents)); + this.#endorsementCache.set(contents, endorsement); + } + return endorsement; + } + + // Strategy 1: Faster when we're sending to most of the group members + // `combined.byRemoving(combine(difference(members, sends)))` + #subtractMemberEndorsements( + serviceIds: Set + ): GroupSendEndorsement { + const difference = this.#memberEndorsementsAcis.difference(serviceIds); + const ourAci = window.textsecure.storage.user.getCheckedAci(); + + const toRemove: Array = []; + for (const serviceId of difference) { + if (serviceId === ourAci) { + // Note: Combined endorsement does not include our aci + continue; + } + + const memberEndorsement = this.#memberEndorsements.get(serviceId); + strictAssert( + memberEndorsement, + 'serializeGroupSendEndorsementFullToken: Missing endorsement' + ); + toRemove.push(this.#toEndorsement(memberEndorsement.endorsement)); + } + + return this.#toEndorsement( + this.#combinedEndorsement.endorsement + ).byRemoving(GroupSendEndorsement.combine(toRemove)); + } + + // Strategy 2: Faster when we're not sending to most of the group members + // `combine(sends)` + #combineMemberEndorsements( + serviceIds: Set + ): GroupSendEndorsement { + const memberEndorsements = Array.from(serviceIds).map(serviceId => { + const memberEndorsement = this.#memberEndorsements.get(serviceId); + strictAssert( + memberEndorsement, + 'serializeGroupSendEndorsementFullToken: Missing endorsement' + ); + return this.#toEndorsement(memberEndorsement.endorsement); + }); + + return GroupSendEndorsement.combine(memberEndorsements); + } + + buildToken(serviceIds: Set): GroupSendToken { + const sendCount = serviceIds.size; + const memberCount = this.#memberEndorsements.size; + const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${memberCount})`; + + let endorsement: GroupSendEndorsement; + if (sendCount === memberCount - 1) { + log.info(`${logId}: combinedEndorsement`); + // Note: Combined endorsement does not include our aci + endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement); + } else if (sendCount > (memberCount - 1) / 2) { + log.info(`${logId}: subtractMemberEndorsements`); + endorsement = this.#subtractMemberEndorsements(serviceIds); + } else { + log.info(`${logId}: combineMemberEndorsements`); + endorsement = this.#combineMemberEndorsements(serviceIds); + } + + const groupSecretParams = new GroupSecretParams( + Buffer.from(this.#groupSecretParamsBase64, 'base64') + ); + + const expiration = this.getExpiration(); + + strictAssert( + isValidGroupSendEndorsementsExpiration(expiration.getTime()), + 'Cannot build token with invalid expiration' + ); + + const fullToken = endorsement.toFullToken(groupSecretParams, expiration); + return toGroupSendToken(fullToken.serialize()); + } +} + +export function onFailedToSendWithEndorsements(error: Error): void { + log.error('onFailedToSendWithEndorsements', Errors.toLogFormat(error)); + window.reduxActions.toast.showToast({ + toastType: ToastType.FailedToSendWithEndorsements, + }); + assertDev(false, 'We should never fail to send with endorsements'); } diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index d71ba779a..c35ddc425 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { differenceWith, omit, partition } from 'lodash'; +import { differenceWith, omit } from 'lodash'; import { v4 as generateUuid } from 'uuid'; import { @@ -23,7 +23,7 @@ import { import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; import * as Errors from '../types/errors'; -import { DataWriter } from '../sql/Client'; +import { DataWriter, DataReader } from '../sql/Client'; import { getValue } from '../RemoteConfig'; import type { ServiceIdString } from '../types/ServiceId'; import { ServiceIdKind } from '../types/ServiceId'; @@ -66,6 +66,12 @@ import { strictAssert } from './assert'; import * as log from '../logging/log'; import { GLOBAL_ZONE } from '../SignalProtocolStore'; import { waitForAll } from './waitForAll'; +import { + GroupSendEndorsementState, + onFailedToSendWithEndorsements, +} from './groupSendEndorsements'; +import { maybeUpdateGroup } from '../groups'; +import type { GroupSendToken } from '../types/GroupSendEndorsements'; const UNKNOWN_RECIPIENT = 404; const INCORRECT_AUTH_KEY = 401; @@ -153,21 +159,7 @@ export async function sendToGroup({ }); } -// Note: This is the group send chokepoint. The 1:1 send chokepoint is sendMessageProto. -export async function sendContentMessageToGroup({ - contentHint, - contentMessage, - isPartialSend, - messageId, - online, - recipients, - sendOptions, - sendTarget, - sendType, - story, - timestamp, - urgent, -}: { +type SendToGroupOptions = Readonly<{ contentHint: number; contentMessage: Proto.Content; isPartialSend?: boolean; @@ -180,7 +172,25 @@ export async function sendContentMessageToGroup({ story?: boolean; timestamp: number; urgent: boolean; -}): Promise { +}>; + +// Note: This is the group send chokepoint. The 1:1 send chokepoint is sendMessageProto. +export async function sendContentMessageToGroup( + options: SendToGroupOptions +): Promise { + const { + contentHint, + contentMessage, + messageId, + online, + recipients, + sendOptions, + sendTarget, + sendType, + story, + timestamp, + urgent, + } = options; const logId = sendTarget.idForLogging(); const accountManager = window.getAccountManager(); @@ -201,21 +211,11 @@ export async function sendContentMessageToGroup({ if (sendTarget.isValid()) { try { - return await sendToGroupViaSenderKey({ - contentHint, - contentMessage, - isPartialSend, - messageId, - online, - recipients, - recursionCount: 0, - sendOptions, - sendTarget, - sendType, - story, - timestamp, - urgent, - }); + return await sendToGroupViaSenderKey( + options, + 0, + 'init (sendContentMessageToGroup)' + ); } catch (error: unknown) { if (!(error instanceof Error)) { throw error; @@ -257,21 +257,11 @@ export async function sendContentMessageToGroup({ // The Primary Sender Key workflow -export async function sendToGroupViaSenderKey(options: { - contentHint: number; - contentMessage: Proto.Content; - isPartialSend?: boolean; - messageId: string | undefined; - online?: boolean; - recipients: ReadonlyArray; - recursionCount: number; - sendOptions?: SendOptionsType; - sendTarget: SenderKeyTargetType; - sendType: SendTypesType; - story?: boolean; - timestamp: number; - urgent: boolean; -}): Promise { +export async function sendToGroupViaSenderKey( + options: SendToGroupOptions, + recursionCount: number, + recursionReason: string +): Promise { const { contentHint, contentMessage, @@ -279,7 +269,6 @@ export async function sendToGroupViaSenderKey(options: { messageId, online, recipients, - recursionCount, sendOptions, sendTarget, sendType, @@ -291,7 +280,7 @@ export async function sendToGroupViaSenderKey(options: { const logId = sendTarget.idForLogging(); log.info( - `sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}...` + `sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}, reason: ${recursionReason}...` ); if (recursionCount > MAX_RECURSION) { @@ -323,8 +312,6 @@ export async function sendToGroupViaSenderKey(options: { ); // 1. Add sender key info if we have none, or clear out if it's too old - const EXPIRE_DURATION = getSenderKeyExpireDuration(); - // Note: From here on, generally need to recurse if we change senderKeyInfo const senderKeyInfo = sendTarget.getSenderKeyInfo(); @@ -339,11 +326,14 @@ export async function sendToGroupViaSenderKey(options: { }); // Restart here because we updated senderKeyInfo - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'Added missing sender key info' + ); } + + const EXPIRE_DURATION = getSenderKeyExpireDuration(); if (isOlderThan(senderKeyInfo.createdAtDate, EXPIRE_DURATION)) { const { createdAtDate } = senderKeyInfo; log.info( @@ -352,10 +342,11 @@ export async function sendToGroupViaSenderKey(options: { await resetSenderKey(sendTarget); // Restart here because we updated senderKeyInfo - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'sender key info expired' + ); } // 2. Fetch all devices we believe we'll be sending to @@ -363,6 +354,50 @@ export async function sendToGroupViaSenderKey(options: { const { devices: currentDevices, emptyServiceIds } = await window.textsecure.storage.protocol.getOpenDevices(ourAci, recipients); + const conversation = + groupId != null + ? (window.ConversationController.get(groupId) ?? null) + : null; + + let groupSendEndorsementState: GroupSendEndorsementState | null = null; + if (groupId != null) { + const data = await DataReader.getGroupSendEndorsementsData(groupId); + if (data == null) { + onFailedToSendWithEndorsements( + new Error( + `sendToGroupViaSenderKey/${logId}: Missing all endorsements for group` + ) + ); + } else { + strictAssert(conversation, 'Must have conversation for endorsements'); + + log.info( + `sendToGroupViaSenderKey/${logId}: Loaded endorsements for ${data.memberEndorsements.length} members` + ); + const groupSecretParamsBase64 = conversation.get('secretParams'); + strictAssert(groupSecretParamsBase64, 'Must have secret params'); + groupSendEndorsementState = new GroupSendEndorsementState( + data, + groupSecretParamsBase64 + ); + + if ( + groupSendEndorsementState != null && + !groupSendEndorsementState.isSafeExpirationRange() + ) { + log.info( + `sendToGroupViaSenderKey/${logId}: Endorsements close to expiration (${groupSendEndorsementState.getExpiration().getTime()}, ${Date.now()}), refreshing group` + ); + await maybeUpdateGroup({ conversation }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'group send endorsements outside expiration range' + ); + } + } + } + // 3. If we have no open sessions with people we believe we are sending to, and we // believe that any have signal accounts, fetch their prekey bundle and start // sessions with them. @@ -370,23 +405,37 @@ export async function sendToGroupViaSenderKey(options: { emptyServiceIds.length > 0 && emptyServiceIds.some(isServiceIdRegistered) ) { - await fetchKeysForServiceIds(emptyServiceIds); + await fetchKeysForServiceIds(emptyServiceIds, groupSendEndorsementState); // Restart here to capture devices for accounts we just started sessions with - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'fetched prekey bundles' + ); } const { memberDevices, distributionId, createdAtDate } = senderKeyInfo; const memberSet = new Set(sendTarget.getMembers()); // 4. Partition devices into sender key and non-sender key groups - const [devicesForSenderKey, devicesForNormalSend] = partition( - currentDevices, - device => isValidSenderKeyRecipient(memberSet, device.serviceId, { story }) - ); + const devicesForSenderKey: Array = []; + const devicesForNormalSend: Array = []; + + for (const device of currentDevices) { + if ( + isValidSenderKeyRecipient( + memberSet, + groupSendEndorsementState, + device.serviceId, + { story } + ) + ) { + devicesForSenderKey.push(device); + } else { + devicesForNormalSend.push(device); + } + } const senderKeyRecipients = getServiceIdsFromDevices(devicesForSenderKey); const normalSendRecipients = getServiceIdsFromDevices(devicesForNormalSend); @@ -425,10 +474,11 @@ export async function sendToGroupViaSenderKey(options: { // Restart here to start over; empty memberDevices means we'll send distribution // message to everyone. - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'removed members in send target' + ); } // 8. If there are new members or new devices in the group, we need to ensure that they @@ -479,10 +529,11 @@ export async function sendToGroupViaSenderKey(options: { // Restart here because we might have discovered new or dropped devices as part of // distributing our sender key. - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'sent skdm to new members' + ); } // 9. Update memberDevices with removals which didn't require a reset. @@ -517,6 +568,17 @@ export async function sendToGroupViaSenderKey(options: { senderKeyRecipientsWithDevices[serviceId].push(id); }); + let groupSendToken: GroupSendToken | undefined; + let accessKeys: Buffer | undefined; + if (groupSendEndorsementState != null) { + strictAssert(conversation, 'Must have conversation for endorsements'); + groupSendToken = groupSendEndorsementState.buildToken( + new Set(senderKeyRecipients) + ); + } else { + accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story }); + } + try { const messageBuffer = await encryptForSenderKey({ contentHint, @@ -525,11 +587,11 @@ export async function sendToGroupViaSenderKey(options: { contentMessage: Proto.Content.encode(contentMessage).finish(), groupId, }); - const accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story }); const result = await window.textsecure.messaging.server.sendWithSenderKey( messageBuffer, accessKeys, + groupSendToken, timestamp, { online, story, urgent } ); @@ -574,30 +636,34 @@ export async function sendToGroupViaSenderKey(options: { } } catch (error) { if (error.code === UNKNOWN_RECIPIENT) { + onFailedToSendWithEndorsements(error); throw new UnknownRecipientError(); } if (error.code === INCORRECT_AUTH_KEY) { + onFailedToSendWithEndorsements(error); throw new IncorrectSenderKeyAuthError(); } if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) { - await handle409Response(logId, error); + await handle409Response(sendTarget, groupSendEndorsementState, error); // Restart here to capture the right set of devices for our next send. - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'error: expired or missing devices' + ); } if (error.code === ERROR_STALE_DEVICES) { - await handle410Response(sendTarget, error); + await handle410Response(sendTarget, groupSendEndorsementState, error); // Restart here to use the right registrationIds for devices we already knew about, // as well as send our sender key to these re-registered or re-linked devices. - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'error: stale devices' + ); } if ( error instanceof LibSignalErrorBase && @@ -615,10 +681,11 @@ export async function sendToGroupViaSenderKey(options: { await DataWriter.updateConversation(brokenAccount.attributes); // Now that we've eliminate this problematic account, we can try the send again. - return sendToGroupViaSenderKey({ - ...options, - recursionCount: recursionCount + 1, - }); + return sendToGroupViaSenderKey( + options, + recursionCount + 1, + 'error: invalid registration id' + ); } } @@ -938,7 +1005,12 @@ function isServiceIdRegistered(serviceId: ServiceIdString) { return !isUnregistered; } -async function handle409Response(logId: string, error: HTTPError) { +async function handle409Response( + sendTarget: SenderKeyTargetType, + groupSendEndorsementState: GroupSendEndorsementState | null, + error: HTTPError +) { + const logId = sendTarget.idForLogging(); const parsed = multiRecipient409ResponseSchema.safeParse(error.response); if (parsed.success) { await waitForAll({ @@ -946,7 +1018,11 @@ async function handle409Response(logId: string, error: HTTPError) { const { uuid, devices } = item; // Start new sessions with devices we didn't know about before if (devices.missingDevices && devices.missingDevices.length > 0) { - await fetchKeysForServiceId(uuid, devices.missingDevices); + await fetchKeysForServiceId( + uuid, + devices.missingDevices, + groupSendEndorsementState + ); } // Archive sessions with devices that have been removed @@ -976,6 +1052,7 @@ async function handle409Response(logId: string, error: HTTPError) { async function handle410Response( sendTarget: SenderKeyTargetType, + groupSendEndorsementState: GroupSendEndorsementState | null, error: HTTPError ) { const logId = sendTarget.idForLogging(); @@ -998,7 +1075,11 @@ async function handle410Response( }); // Start new sessions with these devices - await fetchKeysForServiceId(uuid, devices.staleDevices); + await fetchKeysForServiceId( + uuid, + devices.staleDevices, + groupSendEndorsementState + ); // Forget that we've sent our sender key to these devices, since they've // been re-registered or re-linked. @@ -1053,6 +1134,10 @@ function getXorOfAccessKeys( if (!accessKey) { throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`); } + strictAssert( + typeof accessKey === 'string', + 'Cannot be endorsement in getXorOfAccessKeys' + ); const accessKeyBuffer = Buffer.from(accessKey, 'base64'); if (accessKeyBuffer.length !== ACCESS_KEY_LENGTH) { @@ -1154,6 +1239,7 @@ async function encryptForSenderKey({ function isValidSenderKeyRecipient( members: Set, + groupSendEndorsementState: GroupSendEndorsementState | null, serviceId: ServiceIdString, { story }: { story?: boolean } = {} ): boolean { @@ -1172,7 +1258,17 @@ function isValidSenderKeyRecipient( return false; } - if (!getAccessKey(memberConversation.attributes, { story })) { + if (groupSendEndorsementState != null) { + const memberEndorsement = groupSendEndorsementState.hasMember(serviceId); + if (memberEndorsement == null) { + onFailedToSendWithEndorsements( + new Error( + `isValidSenderKeyRecipient: Sending to ${serviceId}, missing endorsement` + ) + ); + return false; + } + } else if (!getAccessKey(memberConversation.attributes, { story })) { return false; } @@ -1267,7 +1363,7 @@ function getOurAddress(): Address { function getAccessKey( attributes: ConversationAttributesType, { story }: { story?: boolean } -): string | undefined { +): string | null { const { sealedSender, accessKey } = attributes; if (story) { @@ -1275,7 +1371,7 @@ function getAccessKey( } if (sealedSender === SEALED_SENDER.ENABLED) { - return accessKey || undefined; + return accessKey || null; } if (sealedSender === SEALED_SENDER.UNKNOWN) { @@ -1286,11 +1382,12 @@ function getAccessKey( return ZERO_ACCESS_KEY; } - return undefined; + return null; } async function fetchKeysForServiceIds( - serviceIds: Array + serviceIds: Array, + groupSendEndorsementState: GroupSendEndorsementState | null ): Promise { log.info( `fetchKeysForServiceIds: Fetching keys for ${serviceIds.length} serviceIds` @@ -1299,7 +1396,8 @@ async function fetchKeysForServiceIds( try { await waitForAll({ tasks: serviceIds.map( - serviceId => async () => fetchKeysForServiceId(serviceId) + serviceId => async () => + fetchKeysForServiceId(serviceId, null, groupSendEndorsementState) ), }); } catch (error) { @@ -1313,7 +1411,8 @@ async function fetchKeysForServiceIds( async function fetchKeysForServiceId( serviceId: ServiceIdString, - devices?: Array + devices: Array | null, + groupSendEndorsementState: GroupSendEndorsementState | null ): Promise { log.info( `fetchKeysForServiceId: Fetching ${ @@ -1333,11 +1432,19 @@ async function fetchKeysForServiceId( try { // Note: we have no way to make an unrestricted unauthenticated key fetch as part of a // story send, so we hardcode story=false. + const accessKey = getAccessKey(emptyConversation.attributes, { + story: false, + }); + + const groupSendToken = + groupSendEndorsementState?.buildToken(new Set([serviceId])) ?? null; + const { accessKeyFailed } = await getKeysForServiceId( serviceId, window.textsecure?.messaging?.server, devices, - getAccessKey(emptyConversation.attributes, { story: false }) + accessKey, + groupSendToken ); if (accessKeyFailed) { log.info( diff --git a/ts/window.d.ts b/ts/window.d.ts index aa158f37a..b0cc3daf1 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -306,6 +306,11 @@ declare global { interface SharedArrayBuffer { __arrayBuffer: never; } + + interface Set { + // Needed until TS upgrade + difference(other: ReadonlySet): Set; + } } export type WhisperType = {