Implement endorsements for group send
This commit is contained in:
parent
5f82c82803
commit
24536e1342
21 changed files with 816 additions and 269 deletions
20
.github/workflows/benchmark.yml
vendored
20
.github/workflows/benchmark.yml
vendored
|
@ -98,6 +98,26 @@ jobs:
|
||||||
# DEBUG: 'mock:benchmarks'
|
# DEBUG: 'mock:benchmarks'
|
||||||
ARTIFACTS_DIR: artifacts/group-send
|
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
|
- name: Run large group send benchmarks with delivery receipts
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
|
@ -784,6 +784,14 @@
|
||||||
"messageformat": "Submit log",
|
"messageformat": "Submit log",
|
||||||
"description": "Label for the decryption error toast button"
|
"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": {
|
"icu:cannotSelectPhotosAndVideosAlongWithFiles": {
|
||||||
"messageformat": "You can't select photos and videos along with files.",
|
"messageformat": "You can't select photos and videos along with files.",
|
||||||
"description": "An error popup when the user has attempted to add an attachment"
|
"description": "An error popup when the user has attempted to add an attachment"
|
||||||
|
|
|
@ -96,6 +96,8 @@ function getToast(toastType: ToastType): AnyToast {
|
||||||
return { toastType: ToastType.FailedToFetchPhoneNumber };
|
return { toastType: ToastType.FailedToFetchPhoneNumber };
|
||||||
case ToastType.FailedToFetchUsername:
|
case ToastType.FailedToFetchUsername:
|
||||||
return { toastType: ToastType.FailedToFetchUsername };
|
return { toastType: ToastType.FailedToFetchUsername };
|
||||||
|
case ToastType.FailedToSendWithEndorsements:
|
||||||
|
return { toastType: ToastType.FailedToSendWithEndorsements };
|
||||||
case ToastType.FileSaved:
|
case ToastType.FileSaved:
|
||||||
return {
|
return {
|
||||||
toastType: ToastType.FileSaved,
|
toastType: ToastType.FileSaved,
|
||||||
|
|
|
@ -285,6 +285,20 @@ export function renderToast({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toastType === ToastType.FailedToSendWithEndorsements) {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
onClose={hideToast}
|
||||||
|
toastAction={{
|
||||||
|
label: i18n('icu:Toast__ActionLabel--SubmitLog'),
|
||||||
|
onClick: onShowDebugLog,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:Toast--FailedToSendWithEndorsements')}
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.FileSaved) {
|
if (toastType === ToastType.FileSaved) {
|
||||||
return (
|
return (
|
||||||
<Toast
|
<Toast
|
||||||
|
@ -335,7 +349,7 @@ export function renderToast({
|
||||||
onClose={hideToast}
|
onClose={hideToast}
|
||||||
style={{ maxWidth: '500px' }}
|
style={{ maxWidth: '500px' }}
|
||||||
toastAction={{
|
toastAction={{
|
||||||
label: i18n('icu:decryptionErrorToastAction'),
|
label: i18n('icu:Toast__ActionLabel--SubmitLog'),
|
||||||
onClick: onShowDebugLog,
|
onClick: onShowDebugLog,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
15
ts/groups.ts
15
ts/groups.ts
|
@ -96,7 +96,10 @@ import { SeenStatus } from './MessageSeenStatus';
|
||||||
import { incrementMessageCounter } from './util/incrementMessageCounter';
|
import { incrementMessageCounter } from './util/incrementMessageCounter';
|
||||||
import { sleep } from './util/sleep';
|
import { sleep } from './util/sleep';
|
||||||
import { groupInvitesRoute } from './util/signalRoutes';
|
import { groupInvitesRoute } from './util/signalRoutes';
|
||||||
import { decodeGroupSendEndorsementResponse } from './util/groupSendEndorsements';
|
import {
|
||||||
|
decodeGroupSendEndorsementResponse,
|
||||||
|
isValidGroupSendEndorsementsExpiration,
|
||||||
|
} from './util/groupSendEndorsements';
|
||||||
|
|
||||||
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
|
|
||||||
|
@ -3981,6 +3984,16 @@ async function updateGroupViaLogs({
|
||||||
let cachedEndorsementsExpiration =
|
let cachedEndorsementsExpiration =
|
||||||
await DataReader.getGroupSendCombinedEndorsementExpiration(groupId);
|
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 response: GroupLogResponseType;
|
||||||
let groupSendEndorsementResponse: Uint8Array | null = null;
|
let groupSendEndorsementResponse: Uint8Array | null = null;
|
||||||
const changes: Array<Proto.IGroupChanges> = [];
|
const changes: Array<Proto.IGroupChanges> = [];
|
||||||
|
|
4
ts/model-types.d.ts
vendored
4
ts/model-types.d.ts
vendored
|
@ -374,6 +374,10 @@ export type ConversationAttributesType = {
|
||||||
lastProfile?: ConversationLastProfileType;
|
lastProfile?: ConversationLastProfileType;
|
||||||
needsTitleTransition?: boolean;
|
needsTitleTransition?: boolean;
|
||||||
quotedMessageId?: string | null;
|
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;
|
sealedSender?: unknown;
|
||||||
sentMessageCount?: number;
|
sentMessageCount?: number;
|
||||||
sharedGroupNames?: ReadonlyArray<string>;
|
sharedGroupNames?: ReadonlyArray<string>;
|
||||||
|
|
|
@ -37,7 +37,10 @@ import type {
|
||||||
CallLinkType,
|
CallLinkType,
|
||||||
} from '../types/CallLink';
|
} from '../types/CallLink';
|
||||||
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
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 { SyncTaskType } from '../util/syncTasks';
|
||||||
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
|
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
|
||||||
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||||
|
@ -481,6 +484,13 @@ type ReadableInterface = {
|
||||||
) => Array<ConversationType>;
|
) => Array<ConversationType>;
|
||||||
|
|
||||||
getGroupSendCombinedEndorsementExpiration: (groupId: string) => number | null;
|
getGroupSendCombinedEndorsementExpiration: (groupId: string) => number | null;
|
||||||
|
getGroupSendEndorsementsData: (
|
||||||
|
groupId: string
|
||||||
|
) => GroupSendEndorsementsData | null;
|
||||||
|
getGroupSendMemberEndorsement: (
|
||||||
|
groupId: string,
|
||||||
|
memberAci: AciString
|
||||||
|
) => GroupSendMemberEndorsementRecord | null;
|
||||||
|
|
||||||
getMessageCount: (conversationId?: string) => number;
|
getMessageCount: (conversationId?: string) => number;
|
||||||
getStoryCount: (conversationId: string) => number;
|
getStoryCount: (conversationId: string) => number;
|
||||||
|
|
|
@ -191,7 +191,9 @@ import {
|
||||||
replaceAllEndorsementsForGroup,
|
replaceAllEndorsementsForGroup,
|
||||||
deleteAllEndorsementsForGroup,
|
deleteAllEndorsementsForGroup,
|
||||||
getGroupSendCombinedEndorsementExpiration,
|
getGroupSendCombinedEndorsementExpiration,
|
||||||
} from './server/groupEndorsements';
|
getGroupSendEndorsementsData,
|
||||||
|
getGroupSendMemberEndorsement,
|
||||||
|
} from './server/groupSendEndorsements';
|
||||||
import {
|
import {
|
||||||
attachmentDownloadJobSchema,
|
attachmentDownloadJobSchema,
|
||||||
type AttachmentDownloadJobType,
|
type AttachmentDownloadJobType,
|
||||||
|
@ -265,6 +267,8 @@ export const DataReader: ServerReadableInterface = {
|
||||||
getAllGroupsInvolvingServiceId,
|
getAllGroupsInvolvingServiceId,
|
||||||
|
|
||||||
getGroupSendCombinedEndorsementExpiration,
|
getGroupSendCombinedEndorsementExpiration,
|
||||||
|
getGroupSendEndorsementsData,
|
||||||
|
getGroupSendMemberEndorsement,
|
||||||
|
|
||||||
searchMessages,
|
searchMessages,
|
||||||
|
|
||||||
|
|
|
@ -154,7 +154,7 @@ port.on('message', ({ seq, request }: WrappedWorkerRequest) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const method = (DataInterface as any)[request.method];
|
const method = (DataInterface as any)[request.method];
|
||||||
if (typeof method !== 'function') {
|
if (typeof method !== 'function') {
|
||||||
throw new Error(`Invalid sql method: ${method}`);
|
throw new Error(`Invalid sql method: ${request.method} ${method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
|
@ -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<Array<unknown>>(db, insertCombined).run(insertCombinedParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _replaceMemberEndorsements(
|
|
||||||
db: WritableDB,
|
|
||||||
memberEndorsements: ReadonlyArray<GroupSendMemberEndorsementRecord>
|
|
||||||
) {
|
|
||||||
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<Array<unknown>>(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<Array<unknown>>(db, deleteCombined).run(deleteCombinedParams);
|
|
||||||
prepare<Array<unknown>>(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<Array<unknown>>(db, selectGroup)
|
|
||||||
.pluck()
|
|
||||||
.get(selectGroupParams);
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return groupSendEndorsementExpirationSchema.parse(value);
|
|
||||||
}
|
|
172
ts/sql/server/groupSendEndorsements.ts
Normal file
172
ts/sql/server/groupSendEndorsements.ts
Normal file
|
@ -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<Array<unknown>>(db, deleteCombined).run(deleteCombinedParams);
|
||||||
|
prepare<Array<unknown>>(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<Array<unknown>>(db, insertCombined).run(
|
||||||
|
insertCombinedParams
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
result.changes === 1,
|
||||||
|
'Must update groupSendCombinedEndorsement'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _replaceMemberEndorsements(
|
||||||
|
db: WritableDB,
|
||||||
|
memberEndorsements: ReadonlyArray<GroupSendMemberEndorsementRecord>
|
||||||
|
) {
|
||||||
|
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<Array<unknown>>(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<Array<unknown>>(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<Array<unknown>>(db, selectCombinedEndorsement).get(
|
||||||
|
selectCombinedEndorsementParams
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (combinedEndorsement == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberEndorsements = prepare<Array<unknown>>(
|
||||||
|
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<Array<unknown>>(db, selectMemberEndorsements).get(
|
||||||
|
selectMemberEndorsementsParams
|
||||||
|
);
|
||||||
|
if (row == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return groupSendMemberEndorsementSchema.parse(row);
|
||||||
|
}
|
|
@ -35,6 +35,10 @@ export const DISCARD_COUNT = process.env.DISCARD_COUNT
|
||||||
? parseInt(process.env.DISCARD_COUNT, 10)
|
? parseInt(process.env.DISCARD_COUNT, 10)
|
||||||
: 5;
|
: 5;
|
||||||
|
|
||||||
|
export const BLOCKED_COUNT = process.env.BLOCKED_COUNT
|
||||||
|
? parseInt(process.env.BLOCKED_COUNT, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Can happen if electron exits prematurely
|
// Can happen if electron exits prematurely
|
||||||
process.on('unhandledRejection', reason => {
|
process.on('unhandledRejection', reason => {
|
||||||
console.error('Unhandled rejection:');
|
console.error('Unhandled rejection:');
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
EnvelopeType,
|
EnvelopeType,
|
||||||
ReceiptType,
|
ReceiptType,
|
||||||
} from '@signalapp/mock-server';
|
} from '@signalapp/mock-server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Bootstrap,
|
Bootstrap,
|
||||||
debug,
|
debug,
|
||||||
|
@ -18,6 +17,7 @@ import {
|
||||||
CONVERSATION_SIZE,
|
CONVERSATION_SIZE,
|
||||||
DISCARD_COUNT,
|
DISCARD_COUNT,
|
||||||
GROUP_DELIVERY_RECEIPTS,
|
GROUP_DELIVERY_RECEIPTS,
|
||||||
|
BLOCKED_COUNT,
|
||||||
} from './fixtures';
|
} from './fixtures';
|
||||||
import { stats } from '../../util/benchmark/stats';
|
import { stats } from '../../util/benchmark/stats';
|
||||||
import { sleep } from '../../util/sleep';
|
import { sleep } from '../../util/sleep';
|
||||||
|
@ -72,13 +72,31 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Fill group
|
||||||
for (let i = 0; i < CONVERSATION_SIZE; i += 1) {
|
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 messageTimestamp = bootstrap.getTimestamp();
|
||||||
|
|
||||||
const isLast = i === CONVERSATION_SIZE - 1;
|
const isLast = i === CONVERSATION_SIZE - 1;
|
||||||
|
|
||||||
messages.push(
|
messages.push(
|
||||||
await contact.encryptText(
|
await contact.encryptText(
|
||||||
desktop,
|
desktop,
|
||||||
|
@ -90,17 +108,20 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
messages.push(
|
// Last message should trigger an unread indicator
|
||||||
await phone.encryptSyncRead(desktop, {
|
if (!isLast) {
|
||||||
timestamp: bootstrap.getTimestamp(),
|
messages.push(
|
||||||
messages: [
|
await phone.encryptSyncRead(desktop, {
|
||||||
{
|
timestamp: bootstrap.getTimestamp(),
|
||||||
senderAci: contact.device.aci,
|
messages: [
|
||||||
timestamp: messageTimestamp,
|
{
|
||||||
},
|
senderAci: contact.device.aci,
|
||||||
],
|
timestamp: messageTimestamp,
|
||||||
})
|
},
|
||||||
);
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
debug('encrypted');
|
debug('encrypted');
|
||||||
|
|
||||||
|
@ -114,13 +135,30 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
||||||
|
|
||||||
const item = leftPane
|
const item = leftPane
|
||||||
.locator(
|
.locator(
|
||||||
'.module-conversation-list__item--contact-or-conversation' +
|
`.module-conversation-list__item--contact-or-conversation[data-testid="${group.id}"]`
|
||||||
`>> text="${GROUP_NAME}"`
|
|
||||||
)
|
)
|
||||||
.first();
|
.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();
|
await item.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug('scrolling to bottom of timeline');
|
||||||
|
await window
|
||||||
|
.locator('.module-timeline__messages__at-bottom-detector')
|
||||||
|
.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
debug('finding message in timeline');
|
debug('finding message in timeline');
|
||||||
{
|
{
|
||||||
const item = window
|
const item = window
|
||||||
|
|
|
@ -281,20 +281,21 @@ export default class OutgoingMessage {
|
||||||
|
|
||||||
async getKeysForServiceId(
|
async getKeysForServiceId(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
updateDevices?: Array<number>
|
updateDevices: Array<number> | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sendMetadata } = this;
|
const { sendMetadata } = this;
|
||||||
const info =
|
const info =
|
||||||
sendMetadata && sendMetadata[serviceId]
|
sendMetadata && sendMetadata[serviceId]
|
||||||
? sendMetadata[serviceId]
|
? sendMetadata[serviceId]
|
||||||
: { accessKey: undefined };
|
: { accessKey: null };
|
||||||
const { accessKey } = info;
|
const { accessKey } = info;
|
||||||
|
|
||||||
const { accessKeyFailed } = await getKeysForServiceId(
|
const { accessKeyFailed } = await getKeysForServiceId(
|
||||||
serviceId,
|
serviceId,
|
||||||
this.server,
|
this.server,
|
||||||
updateDevices,
|
updateDevices ?? null,
|
||||||
accessKey
|
accessKey,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
if (accessKeyFailed && !this.failoverServiceIds.includes(serviceId)) {
|
if (accessKeyFailed && !this.failoverServiceIds.includes(serviceId)) {
|
||||||
this.failoverServiceIds.push(serviceId);
|
this.failoverServiceIds.push(serviceId);
|
||||||
|
@ -607,8 +608,8 @@ export default class OutgoingMessage {
|
||||||
return p.then(async () => {
|
return p.then(async () => {
|
||||||
const resetDevices =
|
const resetDevices =
|
||||||
error.code === 410
|
error.code === 410
|
||||||
? response.staleDevices
|
? (response.staleDevices ?? null)
|
||||||
: response.missingDevices;
|
: (response.missingDevices ?? null);
|
||||||
return this.getKeysForServiceId(serviceId, resetDevices).then(
|
return this.getKeysForServiceId(serviceId, resetDevices).then(
|
||||||
// We continue to retry as long as the error code was 409; the assumption is
|
// 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.
|
// that we'll request new device info and the next request will succeed.
|
||||||
|
@ -677,7 +678,7 @@ export default class OutgoingMessage {
|
||||||
serviceId,
|
serviceId,
|
||||||
});
|
});
|
||||||
if (deviceIds.length === 0) {
|
if (deviceIds.length === 0) {
|
||||||
await this.getKeysForServiceId(serviceId);
|
await this.getKeysForServiceId(serviceId, null);
|
||||||
}
|
}
|
||||||
await this.reloadDevicesAndSend(serviceId, true)();
|
await this.reloadDevicesAndSend(serviceId, true)();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -72,6 +72,7 @@ import { SECOND } from '../util/durations';
|
||||||
import { safeParseNumber } from '../util/numbers';
|
import { safeParseNumber } from '../util/numbers';
|
||||||
import { isStagingServer } from '../util/isStagingServer';
|
import { isStagingServer } from '../util/isStagingServer';
|
||||||
import type { IWebSocketResource } from './WebsocketResources';
|
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
|
// 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
|
// web request fails, because it will force it to text. But it is very useful for
|
||||||
|
@ -184,10 +185,12 @@ type PromiseAjaxOptionsType = {
|
||||||
| {
|
| {
|
||||||
unauthenticated?: false;
|
unauthenticated?: false;
|
||||||
accessKey?: string;
|
accessKey?: string;
|
||||||
|
groupSendToken?: GroupSendToken;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
unauthenticated: true;
|
unauthenticated: true;
|
||||||
accessKey: undefined | string;
|
accessKey: undefined | string;
|
||||||
|
groupSendToken: undefined | GroupSendToken;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -330,11 +333,13 @@ async function _promiseAjax(
|
||||||
fetchOptions.headers['Content-Length'] = contentLength.toString();
|
fetchOptions.headers['Content-Length'] = contentLength.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessKey, basicAuth, unauthenticated } = options;
|
const { accessKey, basicAuth, groupSendToken, unauthenticated } = options;
|
||||||
if (basicAuth) {
|
if (basicAuth) {
|
||||||
fetchOptions.headers.Authorization = `Basic ${basicAuth}`;
|
fetchOptions.headers.Authorization = `Basic ${basicAuth}`;
|
||||||
} else if (unauthenticated) {
|
} 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
|
// Access key is already a Base64 string
|
||||||
fetchOptions.headers['Unidentified-Access-Key'] = accessKey;
|
fetchOptions.headers['Unidentified-Access-Key'] = accessKey;
|
||||||
}
|
}
|
||||||
|
@ -708,10 +713,12 @@ type AjaxOptionsType = {
|
||||||
| {
|
| {
|
||||||
unauthenticated?: false;
|
unauthenticated?: false;
|
||||||
accessKey?: string;
|
accessKey?: string;
|
||||||
|
groupSendToken?: GroupSendToken;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
unauthenticated: true;
|
unauthenticated: true;
|
||||||
accessKey: undefined | string;
|
accessKey: undefined | string;
|
||||||
|
groupSendToken: undefined | GroupSendToken;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1279,7 +1286,7 @@ export type WebAPIType = {
|
||||||
getKeysForServiceIdUnauth: (
|
getKeysForServiceIdUnauth: (
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
deviceId?: number,
|
deviceId?: number,
|
||||||
options?: { accessKey?: string }
|
options?: { accessKey?: string; groupSendToken?: GroupSendToken }
|
||||||
) => Promise<ServerKeysType>;
|
) => Promise<ServerKeysType>;
|
||||||
getMyKeyCounts: (serviceIdKind: ServiceIdKind) => Promise<ServerKeyCountType>;
|
getMyKeyCounts: (serviceIdKind: ServiceIdKind) => Promise<ServerKeyCountType>;
|
||||||
getOnboardingStoryManifest: () => Promise<{
|
getOnboardingStoryManifest: () => Promise<{
|
||||||
|
@ -1398,7 +1405,8 @@ export type WebAPIType = {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendWithSenderKey: (
|
sendWithSenderKey: (
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
accessKeys: Uint8Array,
|
accessKeys: Uint8Array | undefined,
|
||||||
|
groupSendToken: GroupSendToken | undefined,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: {
|
options: {
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
|
@ -1868,6 +1876,7 @@ export function initialize({
|
||||||
version,
|
version,
|
||||||
unauthenticated: param.unauthenticated,
|
unauthenticated: param.unauthenticated,
|
||||||
accessKey: param.accessKey,
|
accessKey: param.accessKey,
|
||||||
|
groupSendToken: param.groupSendToken,
|
||||||
abortSignal: param.abortSignal,
|
abortSignal: param.abortSignal,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2204,6 +2213,7 @@ export function initialize({
|
||||||
redactUrl: _createRedactor(hashBase64),
|
redactUrl: _createRedactor(hashBase64),
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2246,6 +2256,7 @@ export function initialize({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken: undefined,
|
||||||
redactUrl: _createRedactor(
|
redactUrl: _createRedactor(
|
||||||
serviceId,
|
serviceId,
|
||||||
profileKeyVersion,
|
profileKeyVersion,
|
||||||
|
@ -2418,6 +2429,7 @@ export function initialize({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2454,6 +2466,7 @@ export function initialize({
|
||||||
},
|
},
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -2469,6 +2482,7 @@ export function initialize({
|
||||||
},
|
},
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -2491,6 +2505,7 @@ export function initialize({
|
||||||
},
|
},
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -2506,6 +2521,7 @@ export function initialize({
|
||||||
urlParameters: `/${serviceId}`,
|
urlParameters: `/${serviceId}`,
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -2595,6 +2611,7 @@ export function initialize({
|
||||||
},
|
},
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -2801,6 +2818,7 @@ export function initialize({
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
});
|
});
|
||||||
|
@ -2836,6 +2854,7 @@ export function initialize({
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
});
|
});
|
||||||
|
@ -2887,6 +2906,7 @@ export function initialize({
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
});
|
});
|
||||||
|
@ -2900,6 +2920,7 @@ export function initialize({
|
||||||
httpType: 'POST',
|
httpType: 'POST',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -2931,6 +2952,7 @@ export function initialize({
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
urlParameters: `?cdn=${cdn}`,
|
urlParameters: `?cdn=${cdn}`,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
|
@ -2962,6 +2984,7 @@ export function initialize({
|
||||||
httpType: 'PUT',
|
httpType: 'PUT',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
jsonData: {
|
jsonData: {
|
||||||
backupIdPublicKey: Bytes.toBase64(backupIdPublicKey),
|
backupIdPublicKey: Bytes.toBase64(backupIdPublicKey),
|
||||||
|
@ -2978,6 +3001,7 @@ export function initialize({
|
||||||
httpType: 'PUT',
|
httpType: 'PUT',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
jsonData: {
|
jsonData: {
|
||||||
|
@ -3018,6 +3042,7 @@ export function initialize({
|
||||||
httpType: 'POST',
|
httpType: 'POST',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
jsonData: {
|
jsonData: {
|
||||||
mediaToDelete: mediaToDelete.map(({ cdn, mediaId }) => {
|
mediaToDelete: mediaToDelete.map(({ cdn, mediaId }) => {
|
||||||
|
@ -3047,6 +3072,7 @@ export function initialize({
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
headers,
|
headers,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
urlParameters: `?${params.join('&')}`,
|
urlParameters: `?${params.join('&')}`,
|
||||||
|
@ -3194,7 +3220,10 @@ export function initialize({
|
||||||
async function getKeysForServiceIdUnauth(
|
async function getKeysForServiceIdUnauth(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
deviceId?: number,
|
deviceId?: number,
|
||||||
{ accessKey }: { accessKey?: string } = {}
|
{
|
||||||
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
|
}: { accessKey?: string; groupSendToken?: GroupSendToken } = {}
|
||||||
) {
|
) {
|
||||||
const keys = (await _ajax({
|
const keys = (await _ajax({
|
||||||
call: 'keys',
|
call: 'keys',
|
||||||
|
@ -3204,6 +3233,7 @@ export function initialize({
|
||||||
validateResponse: { identityKey: 'string', devices: 'object' },
|
validateResponse: { identityKey: 'string', devices: 'object' },
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
})) as ServerKeyResponseType;
|
})) as ServerKeyResponseType;
|
||||||
return handleKeys(keys);
|
return handleKeys(keys);
|
||||||
}
|
}
|
||||||
|
@ -3239,6 +3269,7 @@ export function initialize({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3274,7 +3305,8 @@ export function initialize({
|
||||||
|
|
||||||
async function sendWithSenderKey(
|
async function sendWithSenderKey(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
accessKeys: Uint8Array,
|
accessKeys: Uint8Array | undefined,
|
||||||
|
groupSendToken: GroupSendToken | undefined,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
{
|
{
|
||||||
online,
|
online,
|
||||||
|
@ -3298,7 +3330,8 @@ export function initialize({
|
||||||
urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`,
|
urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: Bytes.toBase64(accessKeys),
|
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
|
||||||
|
groupSendToken,
|
||||||
});
|
});
|
||||||
const parseResult = multiRecipient200ResponseSchema.safeParse(response);
|
const parseResult = multiRecipient200ResponseSchema.safeParse(response);
|
||||||
if (parseResult.success) {
|
if (parseResult.success) {
|
||||||
|
@ -4240,6 +4273,7 @@ export function initialize({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: undefined,
|
accessKey: undefined,
|
||||||
|
groupSendToken: undefined,
|
||||||
redactUrl: _createRedactor(formattedId),
|
redactUrl: _createRedactor(formattedId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -23,18 +23,22 @@ import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import type { ServerKeysType, WebAPIType } from './WebAPI';
|
import type { ServerKeysType, WebAPIType } from './WebAPI';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { isRecord } from '../util/isRecord';
|
import { isRecord } from '../util/isRecord';
|
||||||
|
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||||
|
import { onFailedToSendWithEndorsements } from '../util/groupSendEndorsements';
|
||||||
|
|
||||||
export async function getKeysForServiceId(
|
export async function getKeysForServiceId(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
server: WebAPIType,
|
server: WebAPIType,
|
||||||
devicesToUpdate?: Array<number>,
|
devicesToUpdate: Array<number> | null,
|
||||||
accessKey?: string
|
accessKey: string | null,
|
||||||
|
groupSendToken: GroupSendToken | null
|
||||||
): Promise<{ accessKeyFailed?: boolean }> {
|
): Promise<{ accessKeyFailed?: boolean }> {
|
||||||
try {
|
try {
|
||||||
const { keys, accessKeyFailed } = await getServerKeys(
|
const { keys, accessKeyFailed } = await getServerKeys(
|
||||||
serviceId,
|
serviceId,
|
||||||
server,
|
server,
|
||||||
accessKey
|
accessKey,
|
||||||
|
groupSendToken
|
||||||
);
|
);
|
||||||
|
|
||||||
await handleServerKeys(serviceId, keys, devicesToUpdate);
|
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(
|
async function getServerKeys(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
server: WebAPIType,
|
server: WebAPIType,
|
||||||
accessKey?: string
|
accessKey: string | null,
|
||||||
): Promise<{ accessKeyFailed?: boolean; keys: ServerKeysType }> {
|
groupSendToken: GroupSendToken | null
|
||||||
try {
|
): Promise<{ accessKeyFailed: boolean; keys: ServerKeysType }> {
|
||||||
if (!accessKey) {
|
// Return true only when attempted with access key
|
||||||
return {
|
let accessKeyFailed = false;
|
||||||
keys: await server.getKeysForServiceId(serviceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (accessKey != null) {
|
||||||
keys: await server.getKeysForServiceIdUnauth(serviceId, undefined, {
|
// Try the access key first
|
||||||
accessKey,
|
try {
|
||||||
}),
|
const keys = await server.getKeysForServiceIdUnauth(
|
||||||
};
|
serviceId,
|
||||||
} catch (error: unknown) {
|
undefined,
|
||||||
if (
|
{ accessKey }
|
||||||
accessKey &&
|
);
|
||||||
isRecord(error) &&
|
return { keys, accessKeyFailed };
|
||||||
typeof error.code === 'number' &&
|
} catch (error) {
|
||||||
(error.code === 401 || error.code === 403)
|
accessKeyFailed = true;
|
||||||
) {
|
if (!isUnauthorizedError(error)) {
|
||||||
return {
|
throw error;
|
||||||
accessKeyFailed: true,
|
}
|
||||||
keys: await server.getKeysForServiceId(serviceId),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
async function handleServerKeys(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
response: ServerKeysType,
|
response: ServerKeysType,
|
||||||
devicesToUpdate?: Array<number>
|
devicesToUpdate: Array<number> | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||||
const sessionStore = new Sessions({ ourServiceId: ourAci });
|
const sessionStore = new Sessions({ ourServiceId: ourAci });
|
||||||
|
@ -100,10 +127,7 @@ async function handleServerKeys(
|
||||||
response.devices.map(async device => {
|
response.devices.map(async device => {
|
||||||
const { deviceId, registrationId, pqPreKey, preKey, signedPreKey } =
|
const { deviceId, registrationId, pqPreKey, preKey, signedPreKey } =
|
||||||
device;
|
device;
|
||||||
if (
|
if (devicesToUpdate != null && !devicesToUpdate.includes(deviceId)) {
|
||||||
devicesToUpdate !== undefined &&
|
|
||||||
!devicesToUpdate.includes(deviceId)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,26 @@ export const groupSendMemberEndorsementSchema = z.object({
|
||||||
endorsement: groupSendEndorsementSchema,
|
endorsement: groupSendEndorsementSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const groupSendEndorsementsDataSchema = z.object({
|
export const groupSendEndorsementsDataSchema = z
|
||||||
combinedEndorsement: groupSendCombinedEndorsementSchema,
|
.object({
|
||||||
memberEndorsements: z.array(groupSendMemberEndorsementSchema).min(1),
|
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<typeof groupSendTokenSchema>;
|
||||||
|
|
||||||
|
export function toGroupSendToken(token: Uint8Array): GroupSendToken {
|
||||||
|
return groupSendTokenSchema.parse(token);
|
||||||
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ export enum ToastType {
|
||||||
FailedToDeleteUsername = 'FailedToDeleteUsername',
|
FailedToDeleteUsername = 'FailedToDeleteUsername',
|
||||||
FailedToFetchPhoneNumber = 'FailedToFetchPhoneNumber',
|
FailedToFetchPhoneNumber = 'FailedToFetchPhoneNumber',
|
||||||
FailedToFetchUsername = 'FailedToFetchUsername',
|
FailedToFetchUsername = 'FailedToFetchUsername',
|
||||||
|
FailedToSendWithEndorsements = 'FailedToSendWithEndorsements',
|
||||||
FileSaved = 'FileSaved',
|
FileSaved = 'FileSaved',
|
||||||
FileSize = 'FileSize',
|
FileSize = 'FileSize',
|
||||||
GroupLinkCopied = 'GroupLinkCopied',
|
GroupLinkCopied = 'GroupLinkCopied',
|
||||||
|
@ -105,6 +106,7 @@ export type AnyToast =
|
||||||
| { toastType: ToastType.FailedToDeleteUsername }
|
| { toastType: ToastType.FailedToDeleteUsername }
|
||||||
| { toastType: ToastType.FailedToFetchPhoneNumber }
|
| { toastType: ToastType.FailedToFetchPhoneNumber }
|
||||||
| { toastType: ToastType.FailedToFetchUsername }
|
| { toastType: ToastType.FailedToFetchUsername }
|
||||||
|
| { toastType: ToastType.FailedToSendWithEndorsements }
|
||||||
| { toastType: ToastType.FileSaved; parameters: { fullPath: string } }
|
| { toastType: ToastType.FileSaved; parameters: { fullPath: string } }
|
||||||
| {
|
| {
|
||||||
toastType: ToastType.FileSize;
|
toastType: ToastType.FileSize;
|
||||||
|
|
|
@ -1,19 +1,30 @@
|
||||||
// Copyright 2024 Signal Messenger, LLC
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { Aci } from '@signalapp/libsignal-client';
|
import { Aci } from '@signalapp/libsignal-client';
|
||||||
|
import type {
|
||||||
|
GroupSendCombinedEndorsementRecord,
|
||||||
|
GroupSendMemberEndorsementRecord,
|
||||||
|
GroupSendToken,
|
||||||
|
} from '../types/GroupSendEndorsements';
|
||||||
import {
|
import {
|
||||||
groupSendEndorsementsDataSchema,
|
groupSendEndorsementsDataSchema,
|
||||||
|
toGroupSendToken,
|
||||||
type GroupSendEndorsementsData,
|
type GroupSendEndorsementsData,
|
||||||
} from '../types/GroupSendEndorsements';
|
} from '../types/GroupSendEndorsements';
|
||||||
import { strictAssert } from './assert';
|
import { assertDev, strictAssert } from './assert';
|
||||||
import {
|
import {
|
||||||
GroupSecretParams,
|
GroupSecretParams,
|
||||||
|
GroupSendEndorsement,
|
||||||
GroupSendEndorsementsResponse,
|
GroupSendEndorsementsResponse,
|
||||||
ServerPublicParams,
|
ServerPublicParams,
|
||||||
} from './zkgroup';
|
} from './zkgroup';
|
||||||
|
import type { AciString, ServiceIdString } from '../types/ServiceId';
|
||||||
import { fromAciObject } from '../types/ServiceId';
|
import { fromAciObject } from '../types/ServiceId';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import type { GroupV2MemberType } from '../model-types';
|
import type { GroupV2MemberType } from '../model-types';
|
||||||
|
import { DurationInSeconds } from './durations';
|
||||||
|
import { ToastType } from '../types/Toast';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
|
|
||||||
export function decodeGroupSendEndorsementResponse({
|
export function decodeGroupSendEndorsementResponse({
|
||||||
groupId,
|
groupId,
|
||||||
|
@ -42,7 +53,7 @@ export function decodeGroupSendEndorsementResponse({
|
||||||
Buffer.from(groupSendEndorsementResponse)
|
Buffer.from(groupSendEndorsementResponse)
|
||||||
);
|
);
|
||||||
|
|
||||||
const expiration = response.getExpiration().getTime();
|
const expiration = response.getExpiration().getTime() / 1000;
|
||||||
|
|
||||||
const localUser = Aci.parseFromServiceIdString(
|
const localUser = Aci.parseFromServiceIdString(
|
||||||
window.textsecure.storage.user.getCheckedAci()
|
window.textsecure.storage.user.getCheckedAci()
|
||||||
|
@ -78,7 +89,7 @@ export function decodeGroupSendEndorsementResponse({
|
||||||
`decodeGroupSendEndorsementResponse: Received endorsements (group: ${idForLogging}, expiration: ${expiration}, members: ${groupMembers.length})`
|
`decodeGroupSendEndorsementResponse: Received endorsements (group: ${idForLogging}, expiration: ${expiration}, members: ${groupMembers.length})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupEndorsementData: GroupSendEndorsementsData = {
|
const groupEndorsementsData: GroupSendEndorsementsData = {
|
||||||
combinedEndorsement: {
|
combinedEndorsement: {
|
||||||
groupId,
|
groupId,
|
||||||
expiration,
|
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<AciString>();
|
||||||
|
#groupSecretParamsBase64: string;
|
||||||
|
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
|
||||||
|
|
||||||
|
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<ServiceIdString>
|
||||||
|
): GroupSendEndorsement {
|
||||||
|
const difference = this.#memberEndorsementsAcis.difference(serviceIds);
|
||||||
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||||
|
|
||||||
|
const toRemove: Array<GroupSendEndorsement> = [];
|
||||||
|
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<ServiceIdString>
|
||||||
|
): 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<ServiceIdString>): 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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { v4 as generateUuid } from 'uuid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -23,7 +23,7 @@ import {
|
||||||
import { Address } from '../types/Address';
|
import { Address } from '../types/Address';
|
||||||
import { QualifiedAddress } from '../types/QualifiedAddress';
|
import { QualifiedAddress } from '../types/QualifiedAddress';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { DataWriter } from '../sql/Client';
|
import { DataWriter, DataReader } from '../sql/Client';
|
||||||
import { getValue } from '../RemoteConfig';
|
import { getValue } from '../RemoteConfig';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import { ServiceIdKind } from '../types/ServiceId';
|
import { ServiceIdKind } from '../types/ServiceId';
|
||||||
|
@ -66,6 +66,12 @@ import { strictAssert } from './assert';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { GLOBAL_ZONE } from '../SignalProtocolStore';
|
import { GLOBAL_ZONE } from '../SignalProtocolStore';
|
||||||
import { waitForAll } from './waitForAll';
|
import { waitForAll } from './waitForAll';
|
||||||
|
import {
|
||||||
|
GroupSendEndorsementState,
|
||||||
|
onFailedToSendWithEndorsements,
|
||||||
|
} from './groupSendEndorsements';
|
||||||
|
import { maybeUpdateGroup } from '../groups';
|
||||||
|
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||||
|
|
||||||
const UNKNOWN_RECIPIENT = 404;
|
const UNKNOWN_RECIPIENT = 404;
|
||||||
const INCORRECT_AUTH_KEY = 401;
|
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.
|
type SendToGroupOptions = Readonly<{
|
||||||
export async function sendContentMessageToGroup({
|
|
||||||
contentHint,
|
|
||||||
contentMessage,
|
|
||||||
isPartialSend,
|
|
||||||
messageId,
|
|
||||||
online,
|
|
||||||
recipients,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget,
|
|
||||||
sendType,
|
|
||||||
story,
|
|
||||||
timestamp,
|
|
||||||
urgent,
|
|
||||||
}: {
|
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
contentMessage: Proto.Content;
|
contentMessage: Proto.Content;
|
||||||
isPartialSend?: boolean;
|
isPartialSend?: boolean;
|
||||||
|
@ -180,7 +172,25 @@ export async function sendContentMessageToGroup({
|
||||||
story?: boolean;
|
story?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}): Promise<CallbackResultType> {
|
}>;
|
||||||
|
|
||||||
|
// Note: This is the group send chokepoint. The 1:1 send chokepoint is sendMessageProto.
|
||||||
|
export async function sendContentMessageToGroup(
|
||||||
|
options: SendToGroupOptions
|
||||||
|
): Promise<CallbackResultType> {
|
||||||
|
const {
|
||||||
|
contentHint,
|
||||||
|
contentMessage,
|
||||||
|
messageId,
|
||||||
|
online,
|
||||||
|
recipients,
|
||||||
|
sendOptions,
|
||||||
|
sendTarget,
|
||||||
|
sendType,
|
||||||
|
story,
|
||||||
|
timestamp,
|
||||||
|
urgent,
|
||||||
|
} = options;
|
||||||
const logId = sendTarget.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
|
|
||||||
const accountManager = window.getAccountManager();
|
const accountManager = window.getAccountManager();
|
||||||
|
@ -201,21 +211,11 @@ export async function sendContentMessageToGroup({
|
||||||
|
|
||||||
if (sendTarget.isValid()) {
|
if (sendTarget.isValid()) {
|
||||||
try {
|
try {
|
||||||
return await sendToGroupViaSenderKey({
|
return await sendToGroupViaSenderKey(
|
||||||
contentHint,
|
options,
|
||||||
contentMessage,
|
0,
|
||||||
isPartialSend,
|
'init (sendContentMessageToGroup)'
|
||||||
messageId,
|
);
|
||||||
online,
|
|
||||||
recipients,
|
|
||||||
recursionCount: 0,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget,
|
|
||||||
sendType,
|
|
||||||
story,
|
|
||||||
timestamp,
|
|
||||||
urgent,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -257,21 +257,11 @@ export async function sendContentMessageToGroup({
|
||||||
|
|
||||||
// The Primary Sender Key workflow
|
// The Primary Sender Key workflow
|
||||||
|
|
||||||
export async function sendToGroupViaSenderKey(options: {
|
export async function sendToGroupViaSenderKey(
|
||||||
contentHint: number;
|
options: SendToGroupOptions,
|
||||||
contentMessage: Proto.Content;
|
recursionCount: number,
|
||||||
isPartialSend?: boolean;
|
recursionReason: string
|
||||||
messageId: string | undefined;
|
): Promise<CallbackResultType> {
|
||||||
online?: boolean;
|
|
||||||
recipients: ReadonlyArray<ServiceIdString>;
|
|
||||||
recursionCount: number;
|
|
||||||
sendOptions?: SendOptionsType;
|
|
||||||
sendTarget: SenderKeyTargetType;
|
|
||||||
sendType: SendTypesType;
|
|
||||||
story?: boolean;
|
|
||||||
timestamp: number;
|
|
||||||
urgent: boolean;
|
|
||||||
}): Promise<CallbackResultType> {
|
|
||||||
const {
|
const {
|
||||||
contentHint,
|
contentHint,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
|
@ -279,7 +269,6 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
messageId,
|
messageId,
|
||||||
online,
|
online,
|
||||||
recipients,
|
recipients,
|
||||||
recursionCount,
|
|
||||||
sendOptions,
|
sendOptions,
|
||||||
sendTarget,
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
|
@ -291,7 +280,7 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
|
|
||||||
const logId = sendTarget.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
log.info(
|
log.info(
|
||||||
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}...`
|
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}, reason: ${recursionReason}...`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (recursionCount > MAX_RECURSION) {
|
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
|
// 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
|
// Note: From here on, generally need to recurse if we change senderKeyInfo
|
||||||
const senderKeyInfo = sendTarget.getSenderKeyInfo();
|
const senderKeyInfo = sendTarget.getSenderKeyInfo();
|
||||||
|
|
||||||
|
@ -339,11 +326,14 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restart here because we updated senderKeyInfo
|
// Restart here because we updated senderKeyInfo
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'Added missing sender key info'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPIRE_DURATION = getSenderKeyExpireDuration();
|
||||||
if (isOlderThan(senderKeyInfo.createdAtDate, EXPIRE_DURATION)) {
|
if (isOlderThan(senderKeyInfo.createdAtDate, EXPIRE_DURATION)) {
|
||||||
const { createdAtDate } = senderKeyInfo;
|
const { createdAtDate } = senderKeyInfo;
|
||||||
log.info(
|
log.info(
|
||||||
|
@ -352,10 +342,11 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
await resetSenderKey(sendTarget);
|
await resetSenderKey(sendTarget);
|
||||||
|
|
||||||
// Restart here because we updated senderKeyInfo
|
// Restart here because we updated senderKeyInfo
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'sender key info expired'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch all devices we believe we'll be sending to
|
// 2. Fetch all devices we believe we'll be sending to
|
||||||
|
@ -363,6 +354,50 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
const { devices: currentDevices, emptyServiceIds } =
|
const { devices: currentDevices, emptyServiceIds } =
|
||||||
await window.textsecure.storage.protocol.getOpenDevices(ourAci, recipients);
|
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
|
// 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
|
// believe that any have signal accounts, fetch their prekey bundle and start
|
||||||
// sessions with them.
|
// sessions with them.
|
||||||
|
@ -370,23 +405,37 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
emptyServiceIds.length > 0 &&
|
emptyServiceIds.length > 0 &&
|
||||||
emptyServiceIds.some(isServiceIdRegistered)
|
emptyServiceIds.some(isServiceIdRegistered)
|
||||||
) {
|
) {
|
||||||
await fetchKeysForServiceIds(emptyServiceIds);
|
await fetchKeysForServiceIds(emptyServiceIds, groupSendEndorsementState);
|
||||||
|
|
||||||
// Restart here to capture devices for accounts we just started sessions with
|
// Restart here to capture devices for accounts we just started sessions with
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'fetched prekey bundles'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { memberDevices, distributionId, createdAtDate } = senderKeyInfo;
|
const { memberDevices, distributionId, createdAtDate } = senderKeyInfo;
|
||||||
const memberSet = new Set(sendTarget.getMembers());
|
const memberSet = new Set(sendTarget.getMembers());
|
||||||
|
|
||||||
// 4. Partition devices into sender key and non-sender key groups
|
// 4. Partition devices into sender key and non-sender key groups
|
||||||
const [devicesForSenderKey, devicesForNormalSend] = partition(
|
const devicesForSenderKey: Array<DeviceType> = [];
|
||||||
currentDevices,
|
const devicesForNormalSend: Array<DeviceType> = [];
|
||||||
device => isValidSenderKeyRecipient(memberSet, device.serviceId, { story })
|
|
||||||
);
|
for (const device of currentDevices) {
|
||||||
|
if (
|
||||||
|
isValidSenderKeyRecipient(
|
||||||
|
memberSet,
|
||||||
|
groupSendEndorsementState,
|
||||||
|
device.serviceId,
|
||||||
|
{ story }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
devicesForSenderKey.push(device);
|
||||||
|
} else {
|
||||||
|
devicesForNormalSend.push(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const senderKeyRecipients = getServiceIdsFromDevices(devicesForSenderKey);
|
const senderKeyRecipients = getServiceIdsFromDevices(devicesForSenderKey);
|
||||||
const normalSendRecipients = getServiceIdsFromDevices(devicesForNormalSend);
|
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
|
// Restart here to start over; empty memberDevices means we'll send distribution
|
||||||
// message to everyone.
|
// message to everyone.
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
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
|
// 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
|
// Restart here because we might have discovered new or dropped devices as part of
|
||||||
// distributing our sender key.
|
// distributing our sender key.
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'sent skdm to new members'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Update memberDevices with removals which didn't require a reset.
|
// 9. Update memberDevices with removals which didn't require a reset.
|
||||||
|
@ -517,6 +568,17 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
senderKeyRecipientsWithDevices[serviceId].push(id);
|
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 {
|
try {
|
||||||
const messageBuffer = await encryptForSenderKey({
|
const messageBuffer = await encryptForSenderKey({
|
||||||
contentHint,
|
contentHint,
|
||||||
|
@ -525,11 +587,11 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
contentMessage: Proto.Content.encode(contentMessage).finish(),
|
contentMessage: Proto.Content.encode(contentMessage).finish(),
|
||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
const accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
|
|
||||||
|
|
||||||
const result = await window.textsecure.messaging.server.sendWithSenderKey(
|
const result = await window.textsecure.messaging.server.sendWithSenderKey(
|
||||||
messageBuffer,
|
messageBuffer,
|
||||||
accessKeys,
|
accessKeys,
|
||||||
|
groupSendToken,
|
||||||
timestamp,
|
timestamp,
|
||||||
{ online, story, urgent }
|
{ online, story, urgent }
|
||||||
);
|
);
|
||||||
|
@ -574,30 +636,34 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === UNKNOWN_RECIPIENT) {
|
if (error.code === UNKNOWN_RECIPIENT) {
|
||||||
|
onFailedToSendWithEndorsements(error);
|
||||||
throw new UnknownRecipientError();
|
throw new UnknownRecipientError();
|
||||||
}
|
}
|
||||||
if (error.code === INCORRECT_AUTH_KEY) {
|
if (error.code === INCORRECT_AUTH_KEY) {
|
||||||
|
onFailedToSendWithEndorsements(error);
|
||||||
throw new IncorrectSenderKeyAuthError();
|
throw new IncorrectSenderKeyAuthError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) {
|
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.
|
// Restart here to capture the right set of devices for our next send.
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'error: expired or missing devices'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (error.code === ERROR_STALE_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,
|
// 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.
|
// as well as send our sender key to these re-registered or re-linked devices.
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'error: stale devices'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error instanceof LibSignalErrorBase &&
|
error instanceof LibSignalErrorBase &&
|
||||||
|
@ -615,10 +681,11 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
await DataWriter.updateConversation(brokenAccount.attributes);
|
await DataWriter.updateConversation(brokenAccount.attributes);
|
||||||
|
|
||||||
// Now that we've eliminate this problematic account, we can try the send again.
|
// Now that we've eliminate this problematic account, we can try the send again.
|
||||||
return sendToGroupViaSenderKey({
|
return sendToGroupViaSenderKey(
|
||||||
...options,
|
options,
|
||||||
recursionCount: recursionCount + 1,
|
recursionCount + 1,
|
||||||
});
|
'error: invalid registration id'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -938,7 +1005,12 @@ function isServiceIdRegistered(serviceId: ServiceIdString) {
|
||||||
return !isUnregistered;
|
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);
|
const parsed = multiRecipient409ResponseSchema.safeParse(error.response);
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
await waitForAll({
|
await waitForAll({
|
||||||
|
@ -946,7 +1018,11 @@ async function handle409Response(logId: string, error: HTTPError) {
|
||||||
const { uuid, devices } = item;
|
const { uuid, devices } = item;
|
||||||
// Start new sessions with devices we didn't know about before
|
// Start new sessions with devices we didn't know about before
|
||||||
if (devices.missingDevices && devices.missingDevices.length > 0) {
|
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
|
// Archive sessions with devices that have been removed
|
||||||
|
@ -976,6 +1052,7 @@ async function handle409Response(logId: string, error: HTTPError) {
|
||||||
|
|
||||||
async function handle410Response(
|
async function handle410Response(
|
||||||
sendTarget: SenderKeyTargetType,
|
sendTarget: SenderKeyTargetType,
|
||||||
|
groupSendEndorsementState: GroupSendEndorsementState | null,
|
||||||
error: HTTPError
|
error: HTTPError
|
||||||
) {
|
) {
|
||||||
const logId = sendTarget.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
|
@ -998,7 +1075,11 @@ async function handle410Response(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start new sessions with these devices
|
// 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
|
// Forget that we've sent our sender key to these devices, since they've
|
||||||
// been re-registered or re-linked.
|
// been re-registered or re-linked.
|
||||||
|
@ -1053,6 +1134,10 @@ function getXorOfAccessKeys(
|
||||||
if (!accessKey) {
|
if (!accessKey) {
|
||||||
throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`);
|
throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`);
|
||||||
}
|
}
|
||||||
|
strictAssert(
|
||||||
|
typeof accessKey === 'string',
|
||||||
|
'Cannot be endorsement in getXorOfAccessKeys'
|
||||||
|
);
|
||||||
|
|
||||||
const accessKeyBuffer = Buffer.from(accessKey, 'base64');
|
const accessKeyBuffer = Buffer.from(accessKey, 'base64');
|
||||||
if (accessKeyBuffer.length !== ACCESS_KEY_LENGTH) {
|
if (accessKeyBuffer.length !== ACCESS_KEY_LENGTH) {
|
||||||
|
@ -1154,6 +1239,7 @@ async function encryptForSenderKey({
|
||||||
|
|
||||||
function isValidSenderKeyRecipient(
|
function isValidSenderKeyRecipient(
|
||||||
members: Set<ConversationModel>,
|
members: Set<ConversationModel>,
|
||||||
|
groupSendEndorsementState: GroupSendEndorsementState | null,
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
{ story }: { story?: boolean } = {}
|
{ story }: { story?: boolean } = {}
|
||||||
): boolean {
|
): boolean {
|
||||||
|
@ -1172,7 +1258,17 @@ function isValidSenderKeyRecipient(
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1267,7 +1363,7 @@ function getOurAddress(): Address {
|
||||||
function getAccessKey(
|
function getAccessKey(
|
||||||
attributes: ConversationAttributesType,
|
attributes: ConversationAttributesType,
|
||||||
{ story }: { story?: boolean }
|
{ story }: { story?: boolean }
|
||||||
): string | undefined {
|
): string | null {
|
||||||
const { sealedSender, accessKey } = attributes;
|
const { sealedSender, accessKey } = attributes;
|
||||||
|
|
||||||
if (story) {
|
if (story) {
|
||||||
|
@ -1275,7 +1371,7 @@ function getAccessKey(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sealedSender === SEALED_SENDER.ENABLED) {
|
if (sealedSender === SEALED_SENDER.ENABLED) {
|
||||||
return accessKey || undefined;
|
return accessKey || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
||||||
|
@ -1286,11 +1382,12 @@ function getAccessKey(
|
||||||
return ZERO_ACCESS_KEY;
|
return ZERO_ACCESS_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchKeysForServiceIds(
|
async function fetchKeysForServiceIds(
|
||||||
serviceIds: Array<ServiceIdString>
|
serviceIds: Array<ServiceIdString>,
|
||||||
|
groupSendEndorsementState: GroupSendEndorsementState | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
log.info(
|
log.info(
|
||||||
`fetchKeysForServiceIds: Fetching keys for ${serviceIds.length} serviceIds`
|
`fetchKeysForServiceIds: Fetching keys for ${serviceIds.length} serviceIds`
|
||||||
|
@ -1299,7 +1396,8 @@ async function fetchKeysForServiceIds(
|
||||||
try {
|
try {
|
||||||
await waitForAll({
|
await waitForAll({
|
||||||
tasks: serviceIds.map(
|
tasks: serviceIds.map(
|
||||||
serviceId => async () => fetchKeysForServiceId(serviceId)
|
serviceId => async () =>
|
||||||
|
fetchKeysForServiceId(serviceId, null, groupSendEndorsementState)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1313,7 +1411,8 @@ async function fetchKeysForServiceIds(
|
||||||
|
|
||||||
async function fetchKeysForServiceId(
|
async function fetchKeysForServiceId(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
devices?: Array<number>
|
devices: Array<number> | null,
|
||||||
|
groupSendEndorsementState: GroupSendEndorsementState | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
log.info(
|
log.info(
|
||||||
`fetchKeysForServiceId: Fetching ${
|
`fetchKeysForServiceId: Fetching ${
|
||||||
|
@ -1333,11 +1432,19 @@ async function fetchKeysForServiceId(
|
||||||
try {
|
try {
|
||||||
// Note: we have no way to make an unrestricted unauthenticated key fetch as part of a
|
// Note: we have no way to make an unrestricted unauthenticated key fetch as part of a
|
||||||
// story send, so we hardcode story=false.
|
// 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(
|
const { accessKeyFailed } = await getKeysForServiceId(
|
||||||
serviceId,
|
serviceId,
|
||||||
window.textsecure?.messaging?.server,
|
window.textsecure?.messaging?.server,
|
||||||
devices,
|
devices,
|
||||||
getAccessKey(emptyConversation.attributes, { story: false })
|
accessKey,
|
||||||
|
groupSendToken
|
||||||
);
|
);
|
||||||
if (accessKeyFailed) {
|
if (accessKeyFailed) {
|
||||||
log.info(
|
log.info(
|
||||||
|
|
5
ts/window.d.ts
vendored
5
ts/window.d.ts
vendored
|
@ -306,6 +306,11 @@ declare global {
|
||||||
interface SharedArrayBuffer {
|
interface SharedArrayBuffer {
|
||||||
__arrayBuffer: never;
|
__arrayBuffer: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Set<T> {
|
||||||
|
// Needed until TS upgrade
|
||||||
|
difference<U>(other: ReadonlySet<U>): Set<T>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WhisperType = {
|
export type WhisperType = {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue