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 = {