Support endorsements for group 1:1 sends
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
parent
76a77a9b7f
commit
e617981e59
26 changed files with 1296 additions and 796 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
@ -7355,7 +7355,6 @@
|
|||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
|
|
@ -3183,9 +3183,13 @@ export async function startApp(): Promise<void> {
|
|||
switch (eventType) {
|
||||
case FETCH_LATEST_ENUM.LOCAL_PROFILE: {
|
||||
log.info('onFetchLatestSync: fetching latest local profile');
|
||||
const ourAci = window.textsecure.storage.user.getAci();
|
||||
const ourE164 = window.textsecure.storage.user.getNumber();
|
||||
await getProfile(ourAci, ourE164);
|
||||
const ourAci = window.textsecure.storage.user.getAci() ?? null;
|
||||
const ourE164 = window.textsecure.storage.user.getNumber() ?? null;
|
||||
await getProfile({
|
||||
serviceId: ourAci,
|
||||
e164: ourE164,
|
||||
groupId: null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
||||
|
|
28
ts/groups.ts
28
ts/groups.ts
|
@ -100,6 +100,7 @@ import {
|
|||
decodeGroupSendEndorsementResponse,
|
||||
isValidGroupSendEndorsementsExpiration,
|
||||
} from './util/groupSendEndorsements';
|
||||
import { getProfile } from './util/getProfile';
|
||||
import { generateMessageId } from './util/generateMessageId';
|
||||
|
||||
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||
|
@ -3020,11 +3021,10 @@ export async function waitThenMaybeUpdateGroup(
|
|||
{ viaFirstStorageSync = false } = {}
|
||||
): Promise<void> {
|
||||
const { conversation } = options;
|
||||
const logId = `waitThenMaybeUpdateGroup(${conversation.idForLogging()})`;
|
||||
|
||||
if (conversation.isBlocked()) {
|
||||
log.info(
|
||||
`waitThenMaybeUpdateGroup: Group ${conversation.idForLogging()} is blocked, returning early`
|
||||
);
|
||||
log.info(`${logId}: Conversation is blocked, returning early`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -3039,12 +3039,13 @@ export async function waitThenMaybeUpdateGroup(
|
|||
) {
|
||||
const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date.now();
|
||||
log.info(
|
||||
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: group update ` +
|
||||
`was fetched recently, skipping for ${waitTime}ms`
|
||||
`${logId}: group update was fetched recently, skipping for ${waitTime}ms`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`${logId}: group update was not fetched recently, queuing update`);
|
||||
|
||||
// Then wait to process all outstanding messages for this conversation
|
||||
await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
|
||||
try {
|
||||
|
@ -3054,7 +3055,7 @@ export async function waitThenMaybeUpdateGroup(
|
|||
conversation.lastSuccessfulGroupFetch = Date.now();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: maybeUpdateGroup failure:`,
|
||||
`${logId}: maybeUpdateGroup failure:`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
|
@ -3072,7 +3073,7 @@ export async function maybeUpdateGroup(
|
|||
}: MaybeUpdatePropsType,
|
||||
{ viaFirstStorageSync = false } = {}
|
||||
): Promise<void> {
|
||||
const logId = conversation.idForLogging();
|
||||
const logId = `maybeUpdateGroup/${conversation.idForLogging()}`;
|
||||
|
||||
try {
|
||||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
|
@ -3091,10 +3092,7 @@ export async function maybeUpdateGroup(
|
|||
{ viaFirstStorageSync }
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`maybeUpdateGroup/${logId}: Failed to update group:`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
log.error(`${logId}: Failed to update group:`, Errors.toLogFormat(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
@ -3205,7 +3203,13 @@ async function updateGroup(
|
|||
);
|
||||
|
||||
profileFetches = Promise.all(
|
||||
contactsWithoutProfileKey.map(contact => contact.getProfiles())
|
||||
contactsWithoutProfileKey.map(contact => {
|
||||
return getProfile({
|
||||
serviceId: contact.getServiceId() ?? null,
|
||||
e164: contact.get('e164') ?? null,
|
||||
groupId: newAttributes.groupId ?? null,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -78,9 +78,10 @@ const callingMessageJobDataSchema = z.object({
|
|||
conversationId: z.string(),
|
||||
protoBase64: z.string(),
|
||||
urgent: z.boolean(),
|
||||
// These two are group-only
|
||||
// These are group-only
|
||||
recipients: z.array(serviceIdSchema).optional(),
|
||||
isPartialSend: z.boolean().optional(),
|
||||
groupId: z.string().optional(),
|
||||
});
|
||||
export type CallingMessageJobData = z.infer<typeof callingMessageJobDataSchema>;
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ export async function sendCallingMessage(
|
|||
urgent,
|
||||
recipients: jobRecipients,
|
||||
isPartialSend,
|
||||
groupId,
|
||||
} = data;
|
||||
|
||||
const recipients = getValidRecipients(
|
||||
|
@ -85,7 +86,9 @@ export async function sendCallingMessage(
|
|||
}
|
||||
|
||||
const sendType = 'callingMessage';
|
||||
const sendOptions = await getSendOptions(conversation.attributes);
|
||||
const sendOptions = await getSendOptions(conversation.attributes, {
|
||||
groupId,
|
||||
});
|
||||
|
||||
const callingMessage = Proto.CallingMessage.decode(
|
||||
Bytes.fromBase64(protoBase64)
|
||||
|
|
|
@ -1171,6 +1171,7 @@ export class ConversationModel extends window.Backbone
|
|||
options: { force?: boolean } = {}
|
||||
): Promise<void> {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
log.info('fetchLatestGroupV2Data: Not groupV2');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -4872,9 +4873,17 @@ export class ConversationModel extends window.Backbone
|
|||
const conversations =
|
||||
this.getMembers() as unknown as Array<ConversationModel>;
|
||||
|
||||
const groupId = isGroupV2(this.attributes)
|
||||
? (this.get('groupId') ?? null)
|
||||
: null;
|
||||
|
||||
await Promise.all(
|
||||
conversations.map(conversation =>
|
||||
getProfile(conversation.getServiceId(), conversation.get('e164'))
|
||||
getProfile({
|
||||
serviceId: conversation.getServiceId() ?? null,
|
||||
e164: conversation.get('e164') ?? null,
|
||||
groupId,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -4916,7 +4925,7 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
}
|
||||
|
||||
async setProfileAvatar(
|
||||
async setAndMaybeFetchProfileAvatar(
|
||||
avatarUrl: undefined | null | string,
|
||||
decryptionKey: Uint8Array
|
||||
): Promise<void> {
|
||||
|
@ -4964,6 +4973,11 @@ export class ConversationModel extends window.Backbone
|
|||
reason,
|
||||
}: { viaStorageServiceSync?: boolean; reason: string }
|
||||
): Promise<boolean> {
|
||||
strictAssert(
|
||||
profileKey == null || profileKey.length > 0,
|
||||
'setProfileKey: Profile key cannot be an empty string'
|
||||
);
|
||||
|
||||
const oldProfileKey = this.get('profileKey');
|
||||
|
||||
// profileKey is a string so we can compare it directly
|
||||
|
|
|
@ -136,7 +136,11 @@ export async function routineProfileRefresh({
|
|||
|
||||
totalCount += 1;
|
||||
try {
|
||||
await getProfileFn(conversation.getServiceId(), conversation.get('e164'));
|
||||
await getProfileFn({
|
||||
serviceId: conversation.getServiceId() ?? null,
|
||||
e164: conversation.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
log.info(
|
||||
`${logId}: refreshed profile for ${conversation.idForLogging()}`
|
||||
);
|
||||
|
|
|
@ -2676,6 +2676,7 @@ export class CallingClass {
|
|||
urgent,
|
||||
isPartialSend,
|
||||
recipients,
|
||||
groupId,
|
||||
});
|
||||
|
||||
log.info('handleSendCallMessageToGroup() completed successfully');
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assertDev } from '../util/assert';
|
||||
import { assertDev, strictAssert } from '../util/assert';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
import type { StorageInterface } from '../types/Storage.d';
|
||||
|
@ -40,11 +40,16 @@ export class OurProfileKeyService {
|
|||
}
|
||||
|
||||
async set(newValue: undefined | Uint8Array): Promise<void> {
|
||||
log.info('Our profile key service: updating profile key');
|
||||
assertDev(this.storage, 'OurProfileKeyService was not initialized');
|
||||
if (newValue) {
|
||||
if (newValue != null) {
|
||||
strictAssert(
|
||||
newValue.byteLength > 0,
|
||||
'Our profile key service: Profile key cannot be empty'
|
||||
);
|
||||
log.info('Our profile key service: updating profile key');
|
||||
await this.storage.put('profileKey', newValue);
|
||||
} else {
|
||||
log.info('Our profile key service: removing profile key');
|
||||
await this.storage.remove('profileKey');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ProfileKeyCredentialRequestContext } from '@signalapp/libsignal-client/zkgroup';
|
||||
import PQueue from 'p-queue';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import type {
|
||||
GetProfileOptionsType,
|
||||
GetProfileUnauthOptionsType,
|
||||
CapabilitiesType,
|
||||
} from '../textsecure/WebAPI';
|
||||
ClientZkProfileOperations,
|
||||
ProfileKeyCredentialRequestContext,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import type { CapabilitiesType, ProfileType } from '../textsecure/WebAPI';
|
||||
import MessageSender from '../textsecure/SendMessage';
|
||||
import type { ServiceIdString } from '../types/ServiceId';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
|
@ -38,6 +37,12 @@ import { HTTPError } from '../textsecure/Errors';
|
|||
import { Address } from '../types/Address';
|
||||
import { QualifiedAddress } from '../types/QualifiedAddress';
|
||||
import { trimForDisplay, verifyAccessKey, decryptProfile } from '../Crypto';
|
||||
import type { ConversationLastProfileType } from '../model-types';
|
||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||
import {
|
||||
maybeCreateGroupSendEndorsementState,
|
||||
onFailedToSendWithEndorsements,
|
||||
} from '../util/groupSendEndorsements';
|
||||
|
||||
type JobType = {
|
||||
resolve: () => void;
|
||||
|
@ -81,7 +86,10 @@ export class ProfileService {
|
|||
log.info('Profile Service initialized');
|
||||
}
|
||||
|
||||
public async get(conversationId: string): Promise<void> {
|
||||
public async get(
|
||||
conversationId: string,
|
||||
groupId: string | null
|
||||
): Promise<void> {
|
||||
const preCheckConversation =
|
||||
window.ConversationController.get(conversationId);
|
||||
if (!preCheckConversation) {
|
||||
|
@ -122,7 +130,7 @@ export class ProfileService {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.fetchProfile(conversation);
|
||||
await this.fetchProfile(conversation, groupId);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
|
@ -220,388 +228,561 @@ export class ProfileService {
|
|||
|
||||
export const profileService = new ProfileService();
|
||||
|
||||
async function doGetProfile(c: ConversationModel): Promise<void> {
|
||||
const idForLogging = c.idForLogging();
|
||||
const { messaging } = window.textsecure;
|
||||
strictAssert(
|
||||
messaging,
|
||||
'getProfile: window.textsecure.messaging not available'
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace ProfileFetchOptions {
|
||||
type Base = ReadonlyDeep<{
|
||||
request: {
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
};
|
||||
}>;
|
||||
type WithVersioned = ReadonlyDeep<{
|
||||
profileKey: string;
|
||||
profileCredentialRequestContext: ProfileKeyCredentialRequestContext | null;
|
||||
request: {
|
||||
profileKeyVersion: string;
|
||||
profileKeyCredentialRequest: string | null;
|
||||
};
|
||||
}>;
|
||||
type WithUnversioned = ReadonlyDeep<{
|
||||
profileKey: null;
|
||||
profileCredentialRequestContext: null;
|
||||
request: {
|
||||
profileKeyVersion: null;
|
||||
profileKeyCredentialRequest: null;
|
||||
};
|
||||
}>;
|
||||
type WithUnauthAccessKey = ReadonlyDeep<{
|
||||
request: { accessKey: string; groupSendToken: null };
|
||||
}>;
|
||||
type WithUnauthGroupSendToken = ReadonlyDeep<{
|
||||
request: {
|
||||
accessKey: null;
|
||||
groupSendToken: GroupSendToken;
|
||||
};
|
||||
}>;
|
||||
type WithAuth = ReadonlyDeep<{
|
||||
request: {
|
||||
accessKey: null;
|
||||
groupSendToken: null;
|
||||
};
|
||||
}>;
|
||||
|
||||
const { updatesUrl } = window.SignalContext.config;
|
||||
strictAssert(
|
||||
typeof updatesUrl === 'string',
|
||||
'getProfile: expected updatesUrl to be a defined string'
|
||||
);
|
||||
export type Unauth =
|
||||
// versioned (unauth)
|
||||
| (Base & WithVersioned & WithUnauthAccessKey)
|
||||
// unversioned (unauth)
|
||||
| (Base & WithUnversioned & WithUnauthAccessKey)
|
||||
| (Base & WithUnversioned & WithUnauthGroupSendToken);
|
||||
|
||||
const clientZkProfileCipher = getClientZkProfileOperations(
|
||||
window.getServerPublicParams()
|
||||
);
|
||||
export type Auth =
|
||||
// unversioned (auth) -- Using lastProfile
|
||||
| (Base & WithVersioned & WithAuth)
|
||||
// unversioned (auth)
|
||||
| (Base & WithUnversioned & WithAuth);
|
||||
}
|
||||
|
||||
export type ProfileFetchUnauthRequestOptions =
|
||||
ProfileFetchOptions.Unauth['request'];
|
||||
|
||||
export type ProfileFetchAuthRequestOptions =
|
||||
ProfileFetchOptions.Auth['request'];
|
||||
|
||||
async function buildProfileFetchOptions({
|
||||
conversation,
|
||||
lastProfile,
|
||||
clientZkProfileCipher,
|
||||
groupId,
|
||||
}: {
|
||||
conversation: ConversationModel;
|
||||
lastProfile: ConversationLastProfileType | null;
|
||||
clientZkProfileCipher: ClientZkProfileOperations;
|
||||
groupId: string | null;
|
||||
}): Promise<ProfileFetchOptions.Auth | ProfileFetchOptions.Unauth> {
|
||||
const logId = `buildGetProfileOptions(${conversation.idForLogging()})`;
|
||||
|
||||
const userLanguages = getUserLanguages(
|
||||
window.SignalContext.getPreferredSystemLocales(),
|
||||
window.SignalContext.getResolvedMessagesLocale()
|
||||
);
|
||||
|
||||
let profile;
|
||||
const profileKey = conversation.get('profileKey');
|
||||
const profileKeyVersion = conversation.deriveProfileKeyVersion();
|
||||
const accessKey = conversation.get('accessKey');
|
||||
const serviceId = conversation.getCheckedServiceId('getProfile');
|
||||
|
||||
c.deriveAccessKeyIfNeeded();
|
||||
|
||||
const profileKey = c.get('profileKey');
|
||||
const profileKeyVersion = c.deriveProfileKeyVersion();
|
||||
const serviceId = c.getCheckedServiceId('getProfile');
|
||||
const lastProfile = c.get('lastProfile');
|
||||
|
||||
let profileCredentialRequestContext:
|
||||
| undefined
|
||||
| ProfileKeyCredentialRequestContext;
|
||||
|
||||
let getProfileOptions: GetProfileOptionsType | GetProfileUnauthOptionsType;
|
||||
|
||||
let accessKey = c.get('accessKey');
|
||||
if (profileKey) {
|
||||
strictAssert(
|
||||
profileKeyVersion != null && accessKey != null,
|
||||
'profileKeyVersion and accessKey are derived from profileKey'
|
||||
`${logId}: profileKeyVersion and accessKey are derived from profileKey`
|
||||
);
|
||||
|
||||
if (!c.hasProfileKeyCredentialExpired()) {
|
||||
getProfileOptions = {
|
||||
accessKey,
|
||||
profileKeyVersion,
|
||||
userLanguages,
|
||||
};
|
||||
} else {
|
||||
log.info(
|
||||
'getProfile: generating profile key credential request for ' +
|
||||
`conversation ${idForLogging}`
|
||||
);
|
||||
|
||||
let profileKeyCredentialRequestHex: undefined | string;
|
||||
({
|
||||
requestHex: profileKeyCredentialRequestHex,
|
||||
context: profileCredentialRequestContext,
|
||||
} = generateProfileKeyCredentialRequest(
|
||||
clientZkProfileCipher,
|
||||
serviceId,
|
||||
profileKey
|
||||
));
|
||||
|
||||
getProfileOptions = {
|
||||
accessKey,
|
||||
userLanguages,
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
||||
if (!conversation.hasProfileKeyCredentialExpired()) {
|
||||
log.info(`${logId}: using unexpired profile key credential`);
|
||||
return {
|
||||
profileKey,
|
||||
profileCredentialRequestContext: null,
|
||||
request: {
|
||||
userLanguages,
|
||||
accessKey,
|
||||
groupSendToken: null,
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
strictAssert(
|
||||
!accessKey,
|
||||
'accessKey have to be absent because there is no profileKey'
|
||||
|
||||
log.info(`${logId}: generating profile key credential request`);
|
||||
const result = generateProfileKeyCredentialRequest(
|
||||
clientZkProfileCipher,
|
||||
serviceId,
|
||||
profileKey
|
||||
);
|
||||
|
||||
if (lastProfile?.profileKeyVersion) {
|
||||
getProfileOptions = {
|
||||
return {
|
||||
profileKey,
|
||||
profileCredentialRequestContext: result.context,
|
||||
request: {
|
||||
userLanguages,
|
||||
accessKey,
|
||||
groupSendToken: null,
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest: result.requestHex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
strictAssert(
|
||||
accessKey == null,
|
||||
`${logId}: accessKey have to be absent because there is no profileKey`
|
||||
);
|
||||
|
||||
// If we have a `lastProfile`, try getting the versioned profile with auth.
|
||||
// Note: We can't try the group send token here because the versioned profile
|
||||
// can't be decrypted without an up to date profile key.
|
||||
if (
|
||||
lastProfile != null &&
|
||||
lastProfile.profileKey != null &&
|
||||
lastProfile.profileKeyVersion != null
|
||||
) {
|
||||
log.info(`${logId}: using last profile key and version`);
|
||||
return {
|
||||
profileKey: lastProfile.profileKey,
|
||||
profileCredentialRequestContext: null,
|
||||
request: {
|
||||
userLanguages,
|
||||
accessKey: null,
|
||||
groupSendToken: null,
|
||||
profileKeyVersion: lastProfile.profileKeyVersion,
|
||||
profileKeyCredentialRequest: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to group send tokens for unversioned profiles
|
||||
if (groupId != null) {
|
||||
log.info(`${logId}: fetching group endorsements`);
|
||||
let result = await maybeCreateGroupSendEndorsementState(groupId, false);
|
||||
|
||||
if (result.state == null && result.didRefreshGroupState) {
|
||||
result = await maybeCreateGroupSendEndorsementState(groupId, true);
|
||||
}
|
||||
|
||||
const groupSendEndorsementState = result.state;
|
||||
const groupSendToken = groupSendEndorsementState?.buildToken(
|
||||
new Set([serviceId])
|
||||
);
|
||||
|
||||
if (groupSendToken != null) {
|
||||
log.info(`${logId}: using group send token`);
|
||||
return {
|
||||
profileKey: null,
|
||||
profileCredentialRequestContext: null,
|
||||
request: {
|
||||
userLanguages,
|
||||
accessKey: null,
|
||||
groupSendToken,
|
||||
profileKeyVersion: null,
|
||||
profileKeyCredentialRequest: null,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
getProfileOptions = { userLanguages };
|
||||
}
|
||||
}
|
||||
|
||||
const isVersioned = Boolean(getProfileOptions.profileKeyVersion);
|
||||
log.info(
|
||||
`getProfile: getting ${isVersioned ? 'versioned' : 'unversioned'} ` +
|
||||
`profile for conversation ${idForLogging}`
|
||||
// Fallback to auth
|
||||
return {
|
||||
profileKey: null,
|
||||
profileCredentialRequestContext: null,
|
||||
request: {
|
||||
userLanguages,
|
||||
accessKey: null,
|
||||
groupSendToken: null,
|
||||
profileKeyVersion: null,
|
||||
profileKeyCredentialRequest: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decryptField(field: string, decryptionKey: Uint8Array): Uint8Array {
|
||||
return decryptProfile(Bytes.fromBase64(field), decryptionKey);
|
||||
}
|
||||
|
||||
function formatTextField(decrypted: Uint8Array): string {
|
||||
return Bytes.toString(trimForDisplay(decrypted));
|
||||
}
|
||||
|
||||
function isFieldDefined(field: string | null | undefined): field is string {
|
||||
return field != null && field.length > 0;
|
||||
}
|
||||
|
||||
function getFetchOptionsLabel(
|
||||
options: ProfileFetchOptions.Auth | ProfileFetchOptions.Unauth
|
||||
) {
|
||||
let versioned: string;
|
||||
if (options.request.profileKeyVersion != null) {
|
||||
versioned = 'versioned';
|
||||
} else {
|
||||
versioned = 'unversioned';
|
||||
}
|
||||
let auth: string;
|
||||
if (options.request.accessKey != null) {
|
||||
auth = 'unauth: accessKey';
|
||||
} else if (options.request.groupSendToken != null) {
|
||||
auth = 'unauth: groupSendToken';
|
||||
} else {
|
||||
auth = 'auth';
|
||||
}
|
||||
return `${versioned}, ${auth}`;
|
||||
}
|
||||
|
||||
async function doGetProfile(
|
||||
c: ConversationModel,
|
||||
groupId: string | null
|
||||
): Promise<void> {
|
||||
const logId = `getProfile(${c.idForLogging()})`;
|
||||
const { messaging } = window.textsecure;
|
||||
strictAssert(
|
||||
messaging,
|
||||
`${logId}: window.textsecure.messaging not available`
|
||||
);
|
||||
|
||||
const { updatesUrl } = window.SignalContext.config;
|
||||
strictAssert(
|
||||
typeof updatesUrl === 'string',
|
||||
`${logId}: expected updatesUrl to be a defined string`
|
||||
);
|
||||
|
||||
const clientZkProfileCipher = getClientZkProfileOperations(
|
||||
window.getServerPublicParams()
|
||||
);
|
||||
|
||||
// Step #: Make sure we have an access key if we have a profile key.
|
||||
c.deriveAccessKeyIfNeeded();
|
||||
|
||||
const serviceId = c.getCheckedServiceId('getProfile');
|
||||
|
||||
// Step #: Grab the profile key and version we last were successful decrypting with
|
||||
// `lastProfile` is saved at the end of `doGetProfile` after successfully decrypting.
|
||||
// `lastProfile` is used in case the `profileKey` was cleared because of a 401/403.
|
||||
// `lastProfile` is cleared when we get a 404 fetching a profile.
|
||||
const lastProfile = c.get('lastProfile');
|
||||
|
||||
// Step #: Build the request options we will use for fetching and decrypting the profile
|
||||
const options = await buildProfileFetchOptions({
|
||||
conversation: c,
|
||||
lastProfile: lastProfile ?? null,
|
||||
clientZkProfileCipher,
|
||||
groupId,
|
||||
});
|
||||
const { request } = options;
|
||||
|
||||
const isVersioned = request.profileKeyVersion != null;
|
||||
log.info(`${logId}: Fetching profile (${getFetchOptionsLabel(options)})`);
|
||||
|
||||
// Step #: Fetch profile
|
||||
let profile: ProfileType;
|
||||
try {
|
||||
if (getProfileOptions.accessKey) {
|
||||
try {
|
||||
profile = await messaging.getProfile(serviceId, getProfileOptions);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HTTPError)) {
|
||||
throw error;
|
||||
}
|
||||
if (error.code === 401 || error.code === 403) {
|
||||
if (isMe(c.attributes)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, removing profileKey`
|
||||
);
|
||||
await c.setProfileKey(undefined, {
|
||||
reason: 'doGetProfile/accessKey/401+403',
|
||||
});
|
||||
|
||||
// Retry fetch using last known profileKeyVersion or fetch
|
||||
// unversioned profile.
|
||||
return doGetProfile(c);
|
||||
}
|
||||
|
||||
if (error.code === 404) {
|
||||
c.set('profileLastFetchedAt', Date.now());
|
||||
await c.removeLastProfile(lastProfile);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
if (request.accessKey != null || request.groupSendToken != null) {
|
||||
profile = await messaging.server.getProfileUnauth(serviceId, request);
|
||||
} else {
|
||||
try {
|
||||
// We won't get the credential, but lets either fetch:
|
||||
// - a versioned profile using last known profileKeyVersion
|
||||
// - some basic profile information (capabilities, badges, etc).
|
||||
profile = await messaging.getProfile(serviceId, getProfileOptions);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError && error.code === 404) {
|
||||
log.info(`getProfile: failed to find a profile for ${idForLogging}`);
|
||||
|
||||
c.set('profileLastFetchedAt', Date.now());
|
||||
await c.removeLastProfile(lastProfile);
|
||||
if (!isVersioned) {
|
||||
log.info(`getProfile: marking ${idForLogging} as unregistered`);
|
||||
c.setUnregistered();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.identityKey) {
|
||||
const identityKeyBytes = Bytes.fromBase64(profile.identityKey);
|
||||
await updateIdentityKey(identityKeyBytes, serviceId);
|
||||
}
|
||||
|
||||
// Update accessKey to prevent race conditions. Since we run asynchronous
|
||||
// requests above - it is possible that someone updates or erases
|
||||
// the profile key from under us.
|
||||
accessKey = c.get('accessKey');
|
||||
|
||||
if (profile.unrestrictedUnidentifiedAccess && profile.unidentifiedAccess) {
|
||||
log.info(
|
||||
`getProfile: setting sealedSender to UNRESTRICTED for conversation ${idForLogging}`
|
||||
strictAssert(
|
||||
!isMe(c.attributes),
|
||||
`${logId}: Should never fetch own profile on auth connection`
|
||||
);
|
||||
c.set({
|
||||
sealedSender: SEALED_SENDER.UNRESTRICTED,
|
||||
});
|
||||
} else if (accessKey && profile.unidentifiedAccess) {
|
||||
const haveCorrectKey = verifyAccessKey(
|
||||
Bytes.fromBase64(accessKey),
|
||||
Bytes.fromBase64(profile.unidentifiedAccess)
|
||||
);
|
||||
|
||||
if (haveCorrectKey) {
|
||||
log.info(
|
||||
`getProfile: setting sealedSender to ENABLED for conversation ${idForLogging}`
|
||||
);
|
||||
c.set({
|
||||
sealedSender: SEALED_SENDER.ENABLED,
|
||||
});
|
||||
} else {
|
||||
log.warn(
|
||||
`getProfile: setting sealedSender to DISABLED for conversation ${idForLogging}`
|
||||
);
|
||||
c.set({
|
||||
sealedSender: SEALED_SENDER.DISABLED,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info(
|
||||
`getProfile: setting sealedSender to DISABLED for conversation ${idForLogging}`
|
||||
);
|
||||
c.set({
|
||||
sealedSender: SEALED_SENDER.DISABLED,
|
||||
});
|
||||
}
|
||||
|
||||
const rawDecryptionKey = c.get('profileKey') || lastProfile?.profileKey;
|
||||
const decryptionKey = rawDecryptionKey
|
||||
? Bytes.fromBase64(rawDecryptionKey)
|
||||
: undefined;
|
||||
if (profile.about) {
|
||||
if (decryptionKey) {
|
||||
const decrypted = decryptProfile(
|
||||
Bytes.fromBase64(profile.about),
|
||||
decryptionKey
|
||||
);
|
||||
c.set('about', Bytes.toString(trimForDisplay(decrypted)));
|
||||
}
|
||||
} else {
|
||||
c.unset('about');
|
||||
}
|
||||
|
||||
if (profile.aboutEmoji) {
|
||||
if (decryptionKey) {
|
||||
const decrypted = decryptProfile(
|
||||
Bytes.fromBase64(profile.aboutEmoji),
|
||||
decryptionKey
|
||||
);
|
||||
c.set('aboutEmoji', Bytes.toString(trimForDisplay(decrypted)));
|
||||
}
|
||||
} else {
|
||||
c.unset('aboutEmoji');
|
||||
}
|
||||
|
||||
if (profile.phoneNumberSharing) {
|
||||
if (decryptionKey) {
|
||||
const decrypted = decryptProfile(
|
||||
Bytes.fromBase64(profile.phoneNumberSharing),
|
||||
decryptionKey
|
||||
);
|
||||
|
||||
// It should be one byte, but be conservative about it and
|
||||
// set `sharingPhoneNumber` to `false` in all cases except [0x01].
|
||||
c.set(
|
||||
'sharingPhoneNumber',
|
||||
decrypted.length === 1 && decrypted[0] === 1
|
||||
);
|
||||
}
|
||||
} else {
|
||||
c.unset('sharingPhoneNumber');
|
||||
}
|
||||
|
||||
if (profile.paymentAddress && isMe(c.attributes)) {
|
||||
await window.storage.put('paymentAddress', profile.paymentAddress);
|
||||
}
|
||||
|
||||
const pastCapabilities = c.get('capabilities');
|
||||
if (profile.capabilities) {
|
||||
c.set({ capabilities: profile.capabilities });
|
||||
} else {
|
||||
c.unset('capabilities');
|
||||
}
|
||||
|
||||
if (isMe(c.attributes)) {
|
||||
const newCapabilities = c.get('capabilities');
|
||||
|
||||
let hasChanged = false;
|
||||
const observedCapabilities = {
|
||||
...window.storage.get('observedCapabilities'),
|
||||
};
|
||||
const newKeys = new Array<string>();
|
||||
for (const key of OBSERVED_CAPABILITY_KEYS) {
|
||||
// Already reported
|
||||
if (observedCapabilities[key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newCapabilities?.[key]) {
|
||||
if (!pastCapabilities?.[key]) {
|
||||
hasChanged = true;
|
||||
newKeys.push(key);
|
||||
}
|
||||
observedCapabilities[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
await window.storage.put('observedCapabilities', observedCapabilities);
|
||||
if (hasChanged) {
|
||||
log.info(
|
||||
'getProfile: detected a capability flip, sending fetch profile',
|
||||
newKeys
|
||||
);
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getFetchLocalProfileSyncMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
|
||||
if (badges.length) {
|
||||
await window.reduxActions.badges.updateOrCreate(badges);
|
||||
c.set({
|
||||
badges: badges.map(badge => ({
|
||||
id: badge.id,
|
||||
...('expiresAt' in badge
|
||||
? {
|
||||
expiresAt: badge.expiresAt,
|
||||
isVisible: badge.isVisible,
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
c.unset('badges');
|
||||
}
|
||||
|
||||
if (profileCredentialRequestContext) {
|
||||
if (profile.credential) {
|
||||
const {
|
||||
credential: profileKeyCredential,
|
||||
expiration: profileKeyCredentialExpiration,
|
||||
} = handleProfileKeyCredential(
|
||||
clientZkProfileCipher,
|
||||
profileCredentialRequestContext,
|
||||
profile.credential
|
||||
);
|
||||
c.set({ profileKeyCredential, profileKeyCredentialExpiration });
|
||||
} else {
|
||||
log.warn(
|
||||
'getProfile: Included credential request, but got no credential. Clearing profileKeyCredential.'
|
||||
);
|
||||
c.unset('profileKeyCredential');
|
||||
}
|
||||
profile = await messaging.server.getProfile(serviceId, request);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof HTTPError)) {
|
||||
throw error;
|
||||
}
|
||||
log.error(`${logId}: Failed to fetch profile`, Errors.toLogFormat(error));
|
||||
|
||||
switch (error.code) {
|
||||
case 401:
|
||||
case 403:
|
||||
if (error instanceof HTTPError) {
|
||||
// Unauthorized/Forbidden
|
||||
if (error.code === 401 || error.code === 403) {
|
||||
if (request.groupSendToken != null) {
|
||||
onFailedToSendWithEndorsements(error);
|
||||
}
|
||||
|
||||
// Step #: Retries for unauthorized access keys and group send tokens
|
||||
if (!isMe(c.attributes)) {
|
||||
// Fallback from failed unauth (access key) request
|
||||
if (request.accessKey != null) {
|
||||
log.warn(
|
||||
`${logId}: Got ${error.code} when using access key, removing profileKey and retrying`
|
||||
);
|
||||
await c.setProfileKey(undefined, {
|
||||
reason: 'doGetProfile/accessKey/401+403',
|
||||
});
|
||||
|
||||
// Retry fetch using last known profileKeyVersion or fetch
|
||||
// unversioned profile.
|
||||
return doGetProfile(c, groupId);
|
||||
}
|
||||
|
||||
// Fallback from failed unauth (group send token) request
|
||||
if (request.groupSendToken != null) {
|
||||
log.warn(`${logId}: Got ${error.code} when using group send token`);
|
||||
return doGetProfile(c, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Step #: Record if the accessKey we have in the conversation is valid
|
||||
const sealedSender = c.get('sealedSender');
|
||||
if (
|
||||
c.get('sealedSender') === SEALED_SENDER.ENABLED ||
|
||||
c.get('sealedSender') === SEALED_SENDER.UNRESTRICTED
|
||||
sealedSender === SEALED_SENDER.ENABLED ||
|
||||
sealedSender === SEALED_SENDER.UNRESTRICTED
|
||||
) {
|
||||
log.warn(
|
||||
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, removing profileKey`
|
||||
);
|
||||
if (!isMe(c.attributes)) {
|
||||
log.warn(
|
||||
`${logId}: Got ${error.code} when using accessKey, removing profileKey`
|
||||
);
|
||||
await c.setProfileKey(undefined, {
|
||||
reason: 'doGetProfile/accessKey/401+403',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (c.get('sealedSender') === SEALED_SENDER.UNKNOWN) {
|
||||
} else if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
||||
log.warn(
|
||||
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, setting sealedSender = DISABLED`
|
||||
`${logId}: Got ${error.code} fetching profile, setting sealedSender = DISABLED`
|
||||
);
|
||||
c.set('sealedSender', SEALED_SENDER.DISABLED);
|
||||
}
|
||||
|
||||
// TODO: Is it safe to ignore these errors?
|
||||
return;
|
||||
default:
|
||||
log.warn(
|
||||
'getProfile failure:',
|
||||
idForLogging,
|
||||
isNumber(error.code)
|
||||
? `code: ${error.code}`
|
||||
: Errors.toLogFormat(error)
|
||||
}
|
||||
|
||||
// Not Found
|
||||
if (error.code === 404) {
|
||||
log.info(`${logId}: Profile not found`);
|
||||
|
||||
c.set('profileLastFetchedAt', Date.now());
|
||||
// Note: Writes to DB:
|
||||
await c.removeLastProfile(lastProfile);
|
||||
|
||||
if (!isVersioned) {
|
||||
log.info(`${logId}: Marking conversation unregistered`);
|
||||
c.setUnregistered();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// throw all unhandled errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Step #: Save `identityKey` to SignalProtocolStore
|
||||
if (isFieldDefined(profile.identityKey)) {
|
||||
const identityKeyBytes = Bytes.fromBase64(profile.identityKey);
|
||||
// Note: Queues some jobs
|
||||
await updateIdentityKey(identityKeyBytes, serviceId);
|
||||
}
|
||||
|
||||
// Step #: Updating `sealedSender` based on the successful response
|
||||
{
|
||||
// Use the most up to date `accessKey` to prevent race conditions.
|
||||
// Since we run asynchronous requests above - it is possible that someone
|
||||
// updates or erases the profile key from under us.
|
||||
const accessKey = c.get('accessKey');
|
||||
let sealedSender: SEALED_SENDER;
|
||||
|
||||
if (isFieldDefined(profile.unidentifiedAccess)) {
|
||||
if (isFieldDefined(profile.unrestrictedUnidentifiedAccess)) {
|
||||
sealedSender = SEALED_SENDER.UNRESTRICTED;
|
||||
} else if (accessKey != null) {
|
||||
const haveCorrectKey = verifyAccessKey(
|
||||
Bytes.fromBase64(accessKey),
|
||||
Bytes.fromBase64(profile.unidentifiedAccess)
|
||||
);
|
||||
throw error;
|
||||
if (haveCorrectKey) {
|
||||
sealedSender = SEALED_SENDER.ENABLED;
|
||||
} else {
|
||||
log.info(
|
||||
`${logId}: Access key mismatch with profile.unidentifiedAccess`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to disabled if we don't have unrestricted access or the correct access key
|
||||
sealedSender ??= SEALED_SENDER.DISABLED;
|
||||
log.info(
|
||||
`${logId}: setting sealedSender to ${SEALED_SENDER[sealedSender]} ` +
|
||||
`(unidentifiedAccess: ${isFieldDefined(profile.unidentifiedAccess)}, ` +
|
||||
`unrestrictedUnidentifiedAccess: ${isFieldDefined(profile.unrestrictedUnidentifiedAccess)}, ` +
|
||||
`accessKey: ${accessKey != null})`
|
||||
);
|
||||
c.set({ sealedSender });
|
||||
}
|
||||
|
||||
// Step #: Grab the current `profileKey` (which may have updated) or the last
|
||||
// profile key we successfully decrypted from.
|
||||
const rawRequestDecryptionKey = options.profileKey ?? lastProfile?.profileKey;
|
||||
const rawUpdatedDecryptionKey =
|
||||
c.get('profileKey') ?? lastProfile?.profileKey;
|
||||
|
||||
const requestDecryptionKey = rawRequestDecryptionKey
|
||||
? Bytes.fromBase64(rawRequestDecryptionKey)
|
||||
: null;
|
||||
const updatedDecryptionKey = rawUpdatedDecryptionKey
|
||||
? Bytes.fromBase64(rawUpdatedDecryptionKey)
|
||||
: null;
|
||||
|
||||
// Step #: Save profile `about` to conversation
|
||||
if (isFieldDefined(profile.about)) {
|
||||
if (updatedDecryptionKey != null) {
|
||||
const decrypted = decryptField(profile.about, updatedDecryptionKey);
|
||||
c.set('about', formatTextField(decrypted));
|
||||
}
|
||||
} else {
|
||||
c.unset('about');
|
||||
}
|
||||
|
||||
// Step #: Save profile `aboutEmoji` to conversation
|
||||
if (isFieldDefined(profile.aboutEmoji)) {
|
||||
if (updatedDecryptionKey != null) {
|
||||
const decrypted = decryptField(profile.aboutEmoji, updatedDecryptionKey);
|
||||
c.set('aboutEmoji', formatTextField(decrypted));
|
||||
}
|
||||
} else {
|
||||
c.unset('aboutEmoji');
|
||||
}
|
||||
|
||||
// Step #: Save profile `phoneNumberSharing` to conversation
|
||||
if (isFieldDefined(profile.phoneNumberSharing)) {
|
||||
if (updatedDecryptionKey != null) {
|
||||
const decrypted = decryptField(
|
||||
profile.phoneNumberSharing,
|
||||
updatedDecryptionKey
|
||||
);
|
||||
// It should be one byte, but be conservative about it and
|
||||
// set `sharingPhoneNumber` to `false` in all cases except [0x01].
|
||||
const sharingPhoneNumber = decrypted.length === 1 && decrypted[0] === 1;
|
||||
c.set('sharingPhoneNumber', sharingPhoneNumber);
|
||||
}
|
||||
} else {
|
||||
c.unset('sharingPhoneNumber');
|
||||
}
|
||||
|
||||
// Step #: Save our own `paymentAddress` to Storage
|
||||
if (isFieldDefined(profile.paymentAddress) && isMe(c.attributes)) {
|
||||
await window.storage.put('paymentAddress', profile.paymentAddress);
|
||||
}
|
||||
|
||||
// Step #: Save profile `capabilities` to conversation
|
||||
const pastCapabilities = c.get('capabilities');
|
||||
if (profile.capabilities != null) {
|
||||
c.set({ capabilities: profile.capabilities });
|
||||
} else {
|
||||
c.unset('capabilities');
|
||||
}
|
||||
|
||||
// Step #: Save our own `observedCapabilities` to Storage and trigger sync if changed
|
||||
if (isMe(c.attributes)) {
|
||||
const newCapabilities = c.get('capabilities');
|
||||
|
||||
let hasChanged = false;
|
||||
const observedCapabilities = {
|
||||
...window.storage.get('observedCapabilities'),
|
||||
};
|
||||
const newKeys = new Array<string>();
|
||||
for (const key of OBSERVED_CAPABILITY_KEYS) {
|
||||
// Already reported
|
||||
if (observedCapabilities[key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newCapabilities?.[key]) {
|
||||
if (!pastCapabilities?.[key]) {
|
||||
hasChanged = true;
|
||||
newKeys.push(key);
|
||||
}
|
||||
observedCapabilities[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
await window.storage.put('observedCapabilities', observedCapabilities);
|
||||
if (hasChanged) {
|
||||
log.info(
|
||||
'getProfile: detected a capability flip, sending fetch profile',
|
||||
newKeys
|
||||
);
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getFetchLocalProfileSyncMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decryptionKeyString = profileKey || lastProfile?.profileKey;
|
||||
const decryptionKey = decryptionKeyString
|
||||
? Bytes.fromBase64(decryptionKeyString)
|
||||
: undefined;
|
||||
// Step #: Save profile `badges` to conversation and update redux
|
||||
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
|
||||
if (badges.length) {
|
||||
window.reduxActions.badges.updateOrCreate(badges);
|
||||
c.set({
|
||||
badges: badges.map(badge => ({
|
||||
id: badge.id,
|
||||
...('expiresAt' in badge
|
||||
? {
|
||||
expiresAt: badge.expiresAt,
|
||||
isVisible: badge.isVisible,
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
c.unset('badges');
|
||||
}
|
||||
|
||||
// Step #: Save updated (or clear if missing) profile `credential` to conversation
|
||||
if (options.profileCredentialRequestContext != null) {
|
||||
if (profile.credential != null && profile.credential.length > 0) {
|
||||
const {
|
||||
credential: profileKeyCredential,
|
||||
expiration: profileKeyCredentialExpiration,
|
||||
} = handleProfileKeyCredential(
|
||||
clientZkProfileCipher,
|
||||
options.profileCredentialRequestContext,
|
||||
profile.credential
|
||||
);
|
||||
c.set({ profileKeyCredential, profileKeyCredentialExpiration });
|
||||
} else {
|
||||
log.warn(
|
||||
`${logId}: Included credential request, but got no credential. Clearing profileKeyCredential.`
|
||||
);
|
||||
c.unset('profileKeyCredential');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Should this track other failures?
|
||||
let isSuccessfullyDecrypted = true;
|
||||
if (profile.name) {
|
||||
if (decryptionKey) {
|
||||
|
||||
// Step #: Save profile `name` to conversation
|
||||
if (isFieldDefined(profile.name)) {
|
||||
if (requestDecryptionKey != null) {
|
||||
try {
|
||||
await c.setEncryptedProfileName(profile.name, decryptionKey);
|
||||
// Note: Writes to DB and saves message
|
||||
await c.setEncryptedProfileName(profile.name, requestDecryptionKey);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'getProfile decryption failure:',
|
||||
idForLogging,
|
||||
`${logId}: Failed to decrypt profile name`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
isSuccessfullyDecrypted = false;
|
||||
await c.set({
|
||||
c.set({
|
||||
profileName: undefined,
|
||||
profileFamilyName: undefined,
|
||||
});
|
||||
|
@ -615,19 +796,22 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
if (decryptionKey) {
|
||||
await c.setProfileAvatar(profile.avatar, decryptionKey);
|
||||
if (requestDecryptionKey != null) {
|
||||
// Note: Fetches avatar
|
||||
await c.setAndMaybeFetchProfileAvatar(
|
||||
profile.avatar,
|
||||
requestDecryptionKey
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
// Forbidden/Not Found
|
||||
if (error.code === 403 || error.code === 404) {
|
||||
log.warn(
|
||||
`getProfile: profile avatar is missing for conversation ${idForLogging}`
|
||||
);
|
||||
log.warn(`${logId}: Profile avatar is missing (${error.code})`);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
`getProfile: failed to decrypt avatar for conversation ${idForLogging}`,
|
||||
`${logId}: Failed to decrypt profile avatar`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
isSuccessfullyDecrypted = false;
|
||||
|
@ -639,12 +823,12 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
|
|||
// After we successfully decrypted - update lastProfile property
|
||||
if (
|
||||
isSuccessfullyDecrypted &&
|
||||
profileKey &&
|
||||
getProfileOptions.profileKeyVersion
|
||||
options.profileKey &&
|
||||
request.profileKeyVersion
|
||||
) {
|
||||
await c.updateLastProfile(lastProfile, {
|
||||
profileKey,
|
||||
profileKeyVersion: getProfileOptions.profileKeyVersion,
|
||||
profileKey: options.profileKey,
|
||||
profileKeyVersion: request.profileKeyVersion,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1395,7 +1395,7 @@ export async function mergeAccountRecord(
|
|||
: PhoneNumberDiscoverability.Discoverable;
|
||||
await window.storage.put('phoneNumberDiscoverability', discoverability);
|
||||
|
||||
if (profileKey) {
|
||||
if (profileKey && profileKey.byteLength > 0) {
|
||||
void ourProfileKeyService.set(profileKey);
|
||||
}
|
||||
|
||||
|
@ -1657,14 +1657,14 @@ export async function mergeAccountRecord(
|
|||
});
|
||||
|
||||
let needsProfileFetch = false;
|
||||
if (profileKey && profileKey.length > 0) {
|
||||
if (profileKey && profileKey.byteLength > 0) {
|
||||
needsProfileFetch = await conversation.setProfileKey(
|
||||
Bytes.toBase64(profileKey),
|
||||
{ viaStorageServiceSync: true, reason: 'mergeAccountRecord' }
|
||||
);
|
||||
|
||||
const avatarUrl = dropNull(accountRecord.avatarUrl);
|
||||
await conversation.setProfileAvatar(avatarUrl, profileKey);
|
||||
await conversation.setAndMaybeFetchProfileAvatar(avatarUrl, profileKey);
|
||||
await window.storage.put('avatarUrl', avatarUrl);
|
||||
}
|
||||
|
||||
|
|
|
@ -127,7 +127,11 @@ class UsernameIntegrityService {
|
|||
private async checkPhoneNumberSharing(): Promise<void> {
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
|
||||
await getProfile(me.getServiceId(), me.get('e164'));
|
||||
await getProfile({
|
||||
serviceId: me.getServiceId() ?? null,
|
||||
e164: me.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
{
|
||||
const localValue = isSharingPhoneNumberWithEverybody();
|
||||
|
|
|
@ -34,7 +34,11 @@ export async function writeProfile(
|
|||
if (!model) {
|
||||
return;
|
||||
}
|
||||
await getProfile(model.getServiceId(), model.get('e164'));
|
||||
await getProfile({
|
||||
serviceId: model.getServiceId() ?? null,
|
||||
e164: model.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
// Encrypt the profile data, update profile, and if needed upload the avatar
|
||||
const {
|
||||
|
|
|
@ -4385,6 +4385,10 @@ function onConversationOpened(
|
|||
throw new Error('onConversationOpened: Conversation not found');
|
||||
}
|
||||
|
||||
const logId = `onConversationOpened(${conversation.idForLogging()})`;
|
||||
|
||||
log.info(`${logId}: Updating newly opened conversation state`);
|
||||
|
||||
if (messageId) {
|
||||
const message = await __DEPRECATED$getMessageById(messageId);
|
||||
|
||||
|
@ -4393,7 +4397,7 @@ function onConversationOpened(
|
|||
return;
|
||||
}
|
||||
|
||||
log.warn(`onOpened: Did not find message ${messageId}`);
|
||||
log.warn(`${logId}: Did not find message ${messageId}`);
|
||||
}
|
||||
|
||||
const { retryPlaceholders } = window.Signal.Services;
|
||||
|
@ -4427,12 +4431,12 @@ function onConversationOpened(
|
|||
promises.push(conversation.fetchLatestGroupV2Data());
|
||||
strictAssert(
|
||||
conversation.throttledMaybeMigrateV1Group !== undefined,
|
||||
'Conversation model should be initialized'
|
||||
`${logId}: Conversation model should be initialized`
|
||||
);
|
||||
promises.push(conversation.throttledMaybeMigrateV1Group());
|
||||
strictAssert(
|
||||
conversation.throttledFetchSMSOnlyUUID !== undefined,
|
||||
'Conversation model should be initialized'
|
||||
`${logId}: Conversation model should be initialized`
|
||||
);
|
||||
promises.push(conversation.throttledFetchSMSOnlyUUID());
|
||||
|
||||
|
@ -4443,7 +4447,7 @@ function onConversationOpened(
|
|||
) {
|
||||
strictAssert(
|
||||
conversation.throttledGetProfiles !== undefined,
|
||||
'Conversation model should be initialized'
|
||||
`${logId}: Conversation model should be initialized`
|
||||
);
|
||||
await conversation.throttledGetProfiles().catch(() => {
|
||||
/* nothing to do here; logging already happened */
|
||||
|
|
|
@ -11,10 +11,14 @@ import { generateAci } from '../types/ServiceId';
|
|||
import { DAY, HOUR, MINUTE, MONTH } from '../util/durations';
|
||||
|
||||
import { routineProfileRefresh } from '../routineProfileRefresh';
|
||||
import type { getProfile } from '../util/getProfile';
|
||||
|
||||
describe('routineProfileRefresh', () => {
|
||||
let sinonSandbox: sinon.SinonSandbox;
|
||||
let getProfileFn: sinon.SinonStub;
|
||||
let getProfileFn: sinon.SinonStub<
|
||||
Parameters<typeof getProfile>,
|
||||
ReturnType<typeof getProfile>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
sinonSandbox = sinon.createSandbox();
|
||||
|
@ -111,16 +115,16 @@ describe('routineProfileRefresh', () => {
|
|||
id: 1,
|
||||
});
|
||||
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
conversation1.getServiceId(),
|
||||
conversation1.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
conversation2.getServiceId(),
|
||||
conversation2.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: conversation1.getServiceId() ?? null,
|
||||
e164: conversation1.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: conversation2.getServiceId() ?? null,
|
||||
e164: conversation2.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips unregistered conversations and those fetched in the last three days', async () => {
|
||||
|
@ -141,21 +145,21 @@ describe('routineProfileRefresh', () => {
|
|||
});
|
||||
|
||||
sinon.assert.calledOnce(getProfileFn);
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
normal.getServiceId(),
|
||||
normal.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
recentlyFetched.getServiceId(),
|
||||
recentlyFetched.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
unregisteredAndStale.getServiceId(),
|
||||
unregisteredAndStale.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: normal.getServiceId() ?? null,
|
||||
e164: normal.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: recentlyFetched.getServiceId() ?? null,
|
||||
e164: recentlyFetched.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: unregisteredAndStale.getServiceId() ?? null,
|
||||
e164: unregisteredAndStale.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips your own conversation', async () => {
|
||||
|
@ -170,16 +174,16 @@ describe('routineProfileRefresh', () => {
|
|||
id: 1,
|
||||
});
|
||||
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
notMe.getServiceId(),
|
||||
notMe.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
me.getServiceId(),
|
||||
me.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: notMe.getServiceId() ?? null,
|
||||
e164: notMe.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: me.getServiceId() ?? null,
|
||||
e164: me.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes your own conversation if profileKeyCredential is expired', async () => {
|
||||
|
@ -198,12 +202,16 @@ describe('routineProfileRefresh', () => {
|
|||
id: 1,
|
||||
});
|
||||
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
notMe.getServiceId(),
|
||||
notMe.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(getProfileFn, me.getServiceId(), me.get('e164'));
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: notMe.getServiceId() ?? null,
|
||||
e164: notMe.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: me.getServiceId() ?? null,
|
||||
e164: me.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips conversations that were refreshed in last three days', async () => {
|
||||
|
@ -236,31 +244,31 @@ describe('routineProfileRefresh', () => {
|
|||
});
|
||||
|
||||
sinon.assert.calledTwice(getProfileFn);
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
neverRefreshed.getServiceId(),
|
||||
neverRefreshed.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
refreshedToday.getServiceId(),
|
||||
refreshedToday.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
refreshedYesterday.getServiceId(),
|
||||
refreshedYesterday.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
refreshedTwoDaysAgo.getServiceId(),
|
||||
refreshedTwoDaysAgo.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
refreshedThreeDaysAgo.getServiceId(),
|
||||
refreshedThreeDaysAgo.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: neverRefreshed.getServiceId() ?? null,
|
||||
e164: neverRefreshed.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: refreshedToday.getServiceId() ?? null,
|
||||
e164: refreshedToday.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: refreshedYesterday.getServiceId() ?? null,
|
||||
e164: refreshedYesterday.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: refreshedTwoDaysAgo.getServiceId() ?? null,
|
||||
e164: refreshedTwoDaysAgo.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: refreshedThreeDaysAgo.getServiceId() ?? null,
|
||||
e164: refreshedThreeDaysAgo.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('only refreshes profiles for the 50 conversations with the oldest profileLastFetchedAt', async () => {
|
||||
|
@ -300,19 +308,19 @@ describe('routineProfileRefresh', () => {
|
|||
});
|
||||
|
||||
[...normalConversations, ...neverFetched].forEach(conversation => {
|
||||
sinon.assert.calledWith(
|
||||
getProfileFn,
|
||||
conversation.getServiceId(),
|
||||
conversation.get('e164')
|
||||
);
|
||||
sinon.assert.calledWith(getProfileFn, {
|
||||
serviceId: conversation.getServiceId() ?? null,
|
||||
e164: conversation.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
[me, ...shouldNotBeIncluded].forEach(conversation => {
|
||||
sinon.assert.neverCalledWith(
|
||||
getProfileFn,
|
||||
conversation.getServiceId(),
|
||||
conversation.get('e164')
|
||||
);
|
||||
sinon.assert.neverCalledWith(getProfileFn, {
|
||||
serviceId: conversation.getServiceId() ?? null,
|
||||
e164: conversation.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -47,10 +47,10 @@ describe('util/profiles', () => {
|
|||
};
|
||||
const service = new ProfileService(getProfileWithLongDelay);
|
||||
|
||||
const promise1 = service.get(SERVICE_ID_1);
|
||||
const promise2 = service.get(SERVICE_ID_2);
|
||||
const promise3 = service.get(SERVICE_ID_3);
|
||||
const promise4 = service.get(SERVICE_ID_4);
|
||||
const promise1 = service.get(SERVICE_ID_1, null);
|
||||
const promise2 = service.get(SERVICE_ID_2, null);
|
||||
const promise3 = service.get(SERVICE_ID_3, null);
|
||||
const promise4 = service.get(SERVICE_ID_4, null);
|
||||
|
||||
service.clearAll('testing');
|
||||
|
||||
|
@ -71,12 +71,12 @@ describe('util/profiles', () => {
|
|||
const service = new ProfileService(getProfileWithIncrement);
|
||||
|
||||
// Queued and immediately started due to concurrency = 3
|
||||
drop(service.get(SERVICE_ID_1));
|
||||
drop(service.get(SERVICE_ID_2));
|
||||
drop(service.get(SERVICE_ID_3));
|
||||
drop(service.get(SERVICE_ID_1, null));
|
||||
drop(service.get(SERVICE_ID_2, null));
|
||||
drop(service.get(SERVICE_ID_3, null));
|
||||
|
||||
// Queued but only run after paused queue restarts
|
||||
const lastPromise = service.get(SERVICE_ID_4);
|
||||
const lastPromise = service.get(SERVICE_ID_4, null);
|
||||
|
||||
const pausePromise = service.pause(5);
|
||||
|
||||
|
@ -101,10 +101,10 @@ describe('util/profiles', () => {
|
|||
const pausePromise = service.pause(5);
|
||||
|
||||
// None of these are even queued
|
||||
const promise1 = service.get(SERVICE_ID_1);
|
||||
const promise2 = service.get(SERVICE_ID_2);
|
||||
const promise3 = service.get(SERVICE_ID_3);
|
||||
const promise4 = service.get(SERVICE_ID_4);
|
||||
const promise1 = service.get(SERVICE_ID_1, null);
|
||||
const promise2 = service.get(SERVICE_ID_2, null);
|
||||
const promise3 = service.get(SERVICE_ID_3, null);
|
||||
const promise4 = service.get(SERVICE_ID_4, null);
|
||||
|
||||
await assert.isRejected(promise1, 'paused queue');
|
||||
await assert.isRejected(promise2, 'paused queue');
|
||||
|
@ -132,19 +132,19 @@ describe('util/profiles', () => {
|
|||
const service = new ProfileService(getProfileWhichThrows);
|
||||
|
||||
// Queued and immediately started due to concurrency = 3
|
||||
const promise1 = service.get(SERVICE_ID_1);
|
||||
const promise2 = service.get(SERVICE_ID_2);
|
||||
const promise3 = service.get(SERVICE_ID_3);
|
||||
const promise1 = service.get(SERVICE_ID_1, null);
|
||||
const promise2 = service.get(SERVICE_ID_2, null);
|
||||
const promise3 = service.get(SERVICE_ID_3, null);
|
||||
|
||||
// Never started, but queued
|
||||
const promise4 = service.get(SERVICE_ID_4);
|
||||
const promise4 = service.get(SERVICE_ID_4, null);
|
||||
|
||||
assert.strictEqual(runCount, 3, 'before await');
|
||||
|
||||
await assert.isRejected(promise1, `fake ${code}`);
|
||||
|
||||
// Never queued
|
||||
const promise5 = service.get(SERVICE_ID_5);
|
||||
const promise5 = service.get(SERVICE_ID_5, null);
|
||||
|
||||
await assert.isRejected(promise2, 'job cancelled');
|
||||
await assert.isRejected(promise3, 'job cancelled');
|
||||
|
@ -168,19 +168,19 @@ describe('util/profiles', () => {
|
|||
const service = new ProfileService(getProfileWhichThrows);
|
||||
|
||||
// Queued and immediately started due to concurrency = 3
|
||||
const promise1 = service.get(SERVICE_ID_1);
|
||||
const promise2 = service.get(SERVICE_ID_2);
|
||||
const promise3 = service.get(SERVICE_ID_3);
|
||||
const promise1 = service.get(SERVICE_ID_1, null);
|
||||
const promise2 = service.get(SERVICE_ID_2, null);
|
||||
const promise3 = service.get(SERVICE_ID_3, null);
|
||||
|
||||
// Never started, but queued
|
||||
const promise4 = service.get(SERVICE_ID_4);
|
||||
const promise4 = service.get(SERVICE_ID_4, null);
|
||||
|
||||
assert.strictEqual(runCount, 3, 'before await');
|
||||
|
||||
await assert.isRejected(promise1, 'fake -1');
|
||||
|
||||
// Queued, because we aren't pausing
|
||||
const promise5 = service.get(SERVICE_ID_5);
|
||||
const promise5 = service.get(SERVICE_ID_5, null);
|
||||
|
||||
await assert.isRejected(promise2, 'job cancelled');
|
||||
await assert.isRejected(promise3, 'job cancelled');
|
||||
|
|
92
ts/test-node/util/mapEmplace_test.ts
Normal file
92
ts/test-node/util/mapEmplace_test.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as sinon from 'sinon';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MapEmplaceOptions } from '../../util/mapEmplace';
|
||||
import { mapEmplace } from '../../util/mapEmplace';
|
||||
|
||||
type InsertFn = NonNullable<MapEmplaceOptions<Map<object, object>>['insert']>;
|
||||
type UpdateFn = NonNullable<MapEmplaceOptions<Map<object, object>>['update']>;
|
||||
|
||||
describe('mapEmplace', () => {
|
||||
it('should insert and not update when key not present', () => {
|
||||
const map = new Map<object, object>();
|
||||
const key = { key: true };
|
||||
const insertValue = { value: 'insertValue' };
|
||||
const updateValue = { value: 'updateValue' };
|
||||
const insert = sinon.spy<InsertFn>(() => insertValue);
|
||||
const update = sinon.spy<UpdateFn>(() => updateValue);
|
||||
|
||||
const resultValue = mapEmplace(map, key, { insert, update });
|
||||
|
||||
assert.equal(resultValue, insertValue);
|
||||
assert.equal(map.get(key), insertValue);
|
||||
assert.equal(insert.callCount, 1);
|
||||
assert.equal(insert.calledWithExactly(key, map), true);
|
||||
assert.equal(update.callCount, 0);
|
||||
});
|
||||
|
||||
it('should not insert when key present', () => {
|
||||
const map = new Map<object, object>();
|
||||
const key = { key: true };
|
||||
const currentValue = { value: 'currentValue' };
|
||||
const insertValue = { value: 'insertValue' };
|
||||
const insert = sinon.spy<InsertFn>(() => insertValue);
|
||||
|
||||
map.set(key, currentValue);
|
||||
const resultValue = mapEmplace(map, key, { insert });
|
||||
|
||||
assert.equal(resultValue, currentValue);
|
||||
assert.equal(map.get(key), currentValue);
|
||||
assert.equal(insert.callCount, 0);
|
||||
});
|
||||
|
||||
it('should update when key present', () => {
|
||||
const map = new Map<object, object>();
|
||||
const key = { key: true };
|
||||
const currentValue = { value: 'currentValue' };
|
||||
const insertValue = { value: 'insertValue' };
|
||||
const updateValue = { value: 'updateValue' };
|
||||
const insert = sinon.spy<InsertFn>(() => insertValue);
|
||||
const update = sinon.spy<UpdateFn>(() => updateValue);
|
||||
|
||||
map.set(key, currentValue);
|
||||
const resultValue = mapEmplace(map, key, { insert, update });
|
||||
|
||||
assert.equal(resultValue, updateValue);
|
||||
assert.equal(map.get(key), updateValue);
|
||||
assert.equal(insert.callCount, 0);
|
||||
assert.equal(update.callCount, 1);
|
||||
assert.equal(update.calledWithExactly(currentValue, key, map), true);
|
||||
});
|
||||
|
||||
it('should throw when key not present and no insert provided', () => {
|
||||
const map = new Map<object, object>();
|
||||
const key = { key: true };
|
||||
const updateValue = { value: 'updateValue' };
|
||||
const update = sinon.spy<UpdateFn>(() => updateValue);
|
||||
|
||||
assert.throws(() => {
|
||||
mapEmplace(map, key, { update });
|
||||
});
|
||||
|
||||
assert.equal(map.has(key), false);
|
||||
assert.equal(update.callCount, 0);
|
||||
});
|
||||
|
||||
it('should return value unmodified when update not provided', () => {
|
||||
const map = new Map<object, object>();
|
||||
const key = { key: true };
|
||||
const currentValue = { value: 'currentValue' };
|
||||
const insertValue = { value: 'insertValue' };
|
||||
const insert = sinon.spy<InsertFn>(() => insertValue);
|
||||
|
||||
map.set(key, currentValue);
|
||||
const resultValue = mapEmplace(map, key, { insert });
|
||||
|
||||
assert.equal(resultValue, currentValue);
|
||||
assert.equal(map.get(key), currentValue);
|
||||
assert.equal(insert.callCount, 0);
|
||||
});
|
||||
});
|
|
@ -42,6 +42,7 @@ import { Sessions, IdentityKeys } from '../LibSignalStores';
|
|||
import { getKeysForServiceId } from './getKeysForServiceId';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import * as log from '../logging/log';
|
||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||
|
||||
export const enum SenderCertificateMode {
|
||||
WithE164,
|
||||
|
@ -306,13 +307,20 @@ export default class OutgoingMessage {
|
|||
serviceId: ServiceIdString,
|
||||
jsonData: ReadonlyArray<MessageType>,
|
||||
timestamp: number,
|
||||
{ accessKey }: { accessKey?: string } = {}
|
||||
{
|
||||
accessKey,
|
||||
groupSendToken,
|
||||
}: {
|
||||
accessKey: string | null;
|
||||
groupSendToken: GroupSendToken | null;
|
||||
} = { accessKey: null, groupSendToken: null }
|
||||
): Promise<void> {
|
||||
let promise;
|
||||
|
||||
if (accessKey) {
|
||||
if (accessKey != null || groupSendToken != null) {
|
||||
promise = this.server.sendMessagesUnauth(serviceId, jsonData, timestamp, {
|
||||
accessKey,
|
||||
groupSendToken,
|
||||
online: this.online,
|
||||
story: this.story,
|
||||
urgent: this.urgent,
|
||||
|
@ -393,7 +401,11 @@ export default class OutgoingMessage {
|
|||
recurse?: boolean
|
||||
): Promise<void> {
|
||||
const { sendMetadata } = this;
|
||||
const { accessKey, senderCertificate } = sendMetadata?.[serviceId] || {};
|
||||
const {
|
||||
accessKey = null,
|
||||
groupSendToken = null,
|
||||
senderCertificate,
|
||||
} = sendMetadata?.[serviceId] || {};
|
||||
|
||||
if (accessKey && !senderCertificate) {
|
||||
log.warn(
|
||||
|
@ -401,7 +413,9 @@ export default class OutgoingMessage {
|
|||
);
|
||||
}
|
||||
|
||||
const sealedSender = Boolean(accessKey && senderCertificate);
|
||||
const sealedSender =
|
||||
(accessKey != null || groupSendToken != null) &&
|
||||
senderCertificate != null;
|
||||
|
||||
// We don't send to ourselves unless sealedSender is enabled
|
||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
||||
|
@ -508,6 +522,7 @@ export default class OutgoingMessage {
|
|||
if (sealedSender) {
|
||||
return this.transmitMessage(serviceId, jsonData, this.timestamp, {
|
||||
accessKey,
|
||||
groupSendToken,
|
||||
}).then(
|
||||
() => {
|
||||
this.recipients[serviceId] = deviceIds;
|
||||
|
|
|
@ -36,8 +36,6 @@ import {
|
|||
import type {
|
||||
ChallengeType,
|
||||
GetGroupLogOptionsType,
|
||||
GetProfileOptionsType,
|
||||
GetProfileUnauthOptionsType,
|
||||
GroupCredentialsType,
|
||||
GroupLogResponseType,
|
||||
ProxiedRequestOptionsType,
|
||||
|
@ -103,12 +101,22 @@ import {
|
|||
getProtoForCallHistory,
|
||||
} from '../util/callDisposition';
|
||||
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types';
|
||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||
|
||||
export type SendIdentifierData =
|
||||
| {
|
||||
accessKey: string;
|
||||
senderCertificate: SerializedCertificateType | null;
|
||||
groupSendToken: null;
|
||||
}
|
||||
| {
|
||||
accessKey: null;
|
||||
senderCertificate: SerializedCertificateType | null;
|
||||
groupSendToken: GroupSendToken;
|
||||
};
|
||||
|
||||
export type SendMetadataType = {
|
||||
[serviceId: ServiceIdString]: {
|
||||
accessKey: string;
|
||||
senderCertificate?: SerializedCertificateType;
|
||||
};
|
||||
[serviceId: ServiceIdString]: SendIdentifierData;
|
||||
};
|
||||
|
||||
export type SendOptionsType = {
|
||||
|
@ -2321,17 +2329,6 @@ export default class MessageSender {
|
|||
// Note: instead of updating these functions, or adding new ones, remove these and go
|
||||
// directly to window.textsecure.messaging.server.<function>
|
||||
|
||||
async getProfile(
|
||||
serviceId: ServiceIdString,
|
||||
options: GetProfileOptionsType | GetProfileUnauthOptionsType
|
||||
): ReturnType<WebAPIType['getProfile']> {
|
||||
if (options.accessKey !== undefined) {
|
||||
return this.server.getProfileUnauth(serviceId, options);
|
||||
}
|
||||
|
||||
return this.server.getProfile(serviceId, options);
|
||||
}
|
||||
|
||||
async getAvatar(path: string): Promise<ReturnType<WebAPIType['getAvatar']>> {
|
||||
return this.server.getAvatar(path);
|
||||
}
|
||||
|
|
|
@ -74,6 +74,10 @@ import { isStagingServer } from '../util/isStagingServer';
|
|||
import type { IWebSocketResource } from './WebsocketResources';
|
||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||
import { parseUnknown, safeParseUnknown } from '../util/schemas';
|
||||
import type {
|
||||
ProfileFetchAuthRequestOptions,
|
||||
ProfileFetchUnauthRequestOptions,
|
||||
} from '../services/profiles';
|
||||
|
||||
// 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
|
||||
|
@ -91,7 +95,7 @@ function resolveLibsignalNetEnvironment(url: string): Net.Environment {
|
|||
}
|
||||
|
||||
function _createRedactor(
|
||||
...toReplace: ReadonlyArray<string | undefined>
|
||||
...toReplace: ReadonlyArray<string | undefined | null>
|
||||
): RedactUrl {
|
||||
// NOTE: It would be nice to remove this cast, but TypeScript doesn't support
|
||||
// it. However, there is [an issue][0] that discusses this in more detail.
|
||||
|
@ -898,31 +902,6 @@ export type CdsLookupOptionsType = Readonly<{
|
|||
useLibsignal?: boolean;
|
||||
}>;
|
||||
|
||||
type GetProfileCommonOptionsType = Readonly<
|
||||
{
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
} & (
|
||||
| {
|
||||
profileKeyVersion?: undefined;
|
||||
profileKeyCredentialRequest?: undefined;
|
||||
}
|
||||
| {
|
||||
profileKeyVersion: string;
|
||||
profileKeyCredentialRequest?: string;
|
||||
}
|
||||
)
|
||||
>;
|
||||
|
||||
export type GetProfileOptionsType = GetProfileCommonOptionsType &
|
||||
Readonly<{
|
||||
accessKey?: undefined;
|
||||
}>;
|
||||
|
||||
export type GetProfileUnauthOptionsType = GetProfileCommonOptionsType &
|
||||
Readonly<{
|
||||
accessKey: string;
|
||||
}>;
|
||||
|
||||
export type GetGroupCredentialsOptionsType = Readonly<{
|
||||
startDayInMs: number;
|
||||
endDayInMs: number;
|
||||
|
@ -1311,14 +1290,14 @@ export type WebAPIType = {
|
|||
}>;
|
||||
getProfile: (
|
||||
serviceId: ServiceIdString,
|
||||
options: GetProfileOptionsType
|
||||
options: ProfileFetchAuthRequestOptions
|
||||
) => Promise<ProfileType>;
|
||||
getAccountForUsername: (
|
||||
options: GetAccountForUsernameOptionsType
|
||||
) => Promise<GetAccountForUsernameResultType>;
|
||||
getProfileUnauth: (
|
||||
serviceId: ServiceIdString,
|
||||
options: GetProfileUnauthOptionsType
|
||||
options: ProfileFetchUnauthRequestOptions
|
||||
) => Promise<ProfileType>;
|
||||
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
|
||||
getSubscriptionConfiguration: (
|
||||
|
@ -1413,7 +1392,8 @@ export type WebAPIType = {
|
|||
messageArray: ReadonlyArray<MessageType>,
|
||||
timestamp: number,
|
||||
options: {
|
||||
accessKey?: string;
|
||||
accessKey: string | null;
|
||||
groupSendToken: GroupSendToken | null;
|
||||
online?: boolean;
|
||||
story?: boolean;
|
||||
urgent?: boolean;
|
||||
|
@ -1421,8 +1401,8 @@ export type WebAPIType = {
|
|||
) => Promise<void>;
|
||||
sendWithSenderKey: (
|
||||
payload: Uint8Array,
|
||||
accessKeys: Uint8Array | undefined,
|
||||
groupSendToken: GroupSendToken | undefined,
|
||||
accessKeys: Uint8Array | null,
|
||||
groupSendToken: GroupSendToken | null,
|
||||
timestamp: number,
|
||||
options: {
|
||||
online?: boolean;
|
||||
|
@ -2177,19 +2157,19 @@ export function initialize({
|
|||
{
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest,
|
||||
}: GetProfileCommonOptionsType
|
||||
}: ProfileFetchAuthRequestOptions | ProfileFetchUnauthRequestOptions
|
||||
) {
|
||||
let profileUrl = `/${serviceId}`;
|
||||
if (profileKeyVersion !== undefined) {
|
||||
if (profileKeyVersion != null) {
|
||||
profileUrl += `/${profileKeyVersion}`;
|
||||
if (profileKeyCredentialRequest !== undefined) {
|
||||
if (profileKeyCredentialRequest != null) {
|
||||
profileUrl +=
|
||||
`/${profileKeyCredentialRequest}` +
|
||||
'?credentialType=expiringProfileKey';
|
||||
}
|
||||
} else {
|
||||
strictAssert(
|
||||
profileKeyCredentialRequest === undefined,
|
||||
profileKeyCredentialRequest == null,
|
||||
'getProfileUrl called without version, but with request'
|
||||
);
|
||||
}
|
||||
|
@ -2199,7 +2179,7 @@ export function initialize({
|
|||
|
||||
async function getProfile(
|
||||
serviceId: ServiceIdString,
|
||||
options: GetProfileOptionsType
|
||||
options: ProfileFetchAuthRequestOptions
|
||||
) {
|
||||
const { profileKeyVersion, profileKeyCredentialRequest, userLanguages } =
|
||||
options;
|
||||
|
@ -2258,15 +2238,25 @@ export function initialize({
|
|||
|
||||
async function getProfileUnauth(
|
||||
serviceId: ServiceIdString,
|
||||
options: GetProfileUnauthOptionsType
|
||||
options: ProfileFetchUnauthRequestOptions
|
||||
) {
|
||||
const {
|
||||
accessKey,
|
||||
groupSendToken,
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest,
|
||||
userLanguages,
|
||||
} = options;
|
||||
|
||||
if (profileKeyVersion != null || profileKeyCredentialRequest != null) {
|
||||
// Without an up-to-date profile key, we won't be able to read the
|
||||
// profile anyways so there's no point in falling back to endorsements.
|
||||
strictAssert(
|
||||
groupSendToken == null,
|
||||
'Should not use endorsements for fetching a versioned profile'
|
||||
);
|
||||
}
|
||||
|
||||
return (await _ajax({
|
||||
call: 'profile',
|
||||
httpType: 'GET',
|
||||
|
@ -2276,8 +2266,8 @@ export function initialize({
|
|||
},
|
||||
responseType: 'json',
|
||||
unauthenticated: true,
|
||||
accessKey,
|
||||
groupSendToken: undefined,
|
||||
accessKey: accessKey ?? undefined,
|
||||
groupSendToken: groupSendToken ?? undefined,
|
||||
redactUrl: _createRedactor(
|
||||
serviceId,
|
||||
profileKeyVersion,
|
||||
|
@ -3273,11 +3263,13 @@ export function initialize({
|
|||
timestamp: number,
|
||||
{
|
||||
accessKey,
|
||||
groupSendToken,
|
||||
online,
|
||||
urgent = true,
|
||||
story = false,
|
||||
}: {
|
||||
accessKey?: string;
|
||||
accessKey: string | null;
|
||||
groupSendToken: GroupSendToken | null;
|
||||
online?: boolean;
|
||||
story?: boolean;
|
||||
urgent?: boolean;
|
||||
|
@ -3297,8 +3289,8 @@ export function initialize({
|
|||
jsonData,
|
||||
responseType: 'json',
|
||||
unauthenticated: true,
|
||||
accessKey,
|
||||
groupSendToken: undefined,
|
||||
accessKey: accessKey ?? undefined,
|
||||
groupSendToken: groupSendToken ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -3334,8 +3326,8 @@ export function initialize({
|
|||
|
||||
async function sendWithSenderKey(
|
||||
data: Uint8Array,
|
||||
accessKeys: Uint8Array | undefined,
|
||||
groupSendToken: GroupSendToken | undefined,
|
||||
accessKeys: Uint8Array | null,
|
||||
groupSendToken: GroupSendToken | null,
|
||||
timestamp: number,
|
||||
{
|
||||
online,
|
||||
|
@ -3360,7 +3352,7 @@ export function initialize({
|
|||
responseType: 'json',
|
||||
unauthenticated: true,
|
||||
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
|
||||
groupSendToken,
|
||||
groupSendToken: groupSendToken ?? undefined,
|
||||
});
|
||||
const parseResult = safeParseUnknown(
|
||||
multiRecipient200ResponseSchema,
|
||||
|
|
|
@ -5,10 +5,15 @@ import * as log from '../logging/log';
|
|||
import { profileService } from '../services/profiles';
|
||||
import type { ServiceIdString } from '../types/ServiceId';
|
||||
|
||||
export async function getProfile(
|
||||
serviceId?: ServiceIdString,
|
||||
e164?: string
|
||||
): Promise<void> {
|
||||
export async function getProfile({
|
||||
serviceId,
|
||||
e164,
|
||||
groupId,
|
||||
}: {
|
||||
serviceId: ServiceIdString | null;
|
||||
e164: string | null;
|
||||
groupId: string | null;
|
||||
}): Promise<void> {
|
||||
const c = window.ConversationController.lookupOrCreate({
|
||||
serviceId,
|
||||
e164,
|
||||
|
@ -19,5 +24,5 @@ export async function getProfile(
|
|||
return;
|
||||
}
|
||||
|
||||
return profileService.get(c.id);
|
||||
return profileService.get(c.id, groupId);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import type {
|
||||
SendIdentifierData,
|
||||
SendMetadataType,
|
||||
SendOptionsType,
|
||||
} from '../textsecure/SendMessage';
|
||||
|
@ -15,6 +16,7 @@ import { shouldSharePhoneNumberWith } from './phoneNumberSharingMode';
|
|||
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
|
||||
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
|
||||
import { isNotNil } from './isNotNil';
|
||||
import { maybeCreateGroupSendEndorsementState } from './groupSendEndorsements';
|
||||
|
||||
const SEALED_SENDER = {
|
||||
UNKNOWN: 0,
|
||||
|
@ -61,9 +63,10 @@ export async function getSendOptionsForRecipients(
|
|||
|
||||
export async function getSendOptions(
|
||||
conversationAttrs: ConversationAttributesType,
|
||||
options: { syncMessage?: boolean; story?: boolean } = {}
|
||||
options: { syncMessage?: boolean; story?: boolean; groupId?: string } = {},
|
||||
alreadyRefreshedGroupState = false
|
||||
): Promise<SendOptionsType> {
|
||||
const { syncMessage, story } = options;
|
||||
const { syncMessage, story, groupId } = options;
|
||||
|
||||
if (!isDirectConversation(conversationAttrs)) {
|
||||
const contactCollection = getConversationMembers(conversationAttrs);
|
||||
|
@ -84,8 +87,6 @@ export async function getSendOptions(
|
|||
return { sendMetadata };
|
||||
}
|
||||
|
||||
const { accessKey, sealedSender } = conversationAttrs;
|
||||
|
||||
// We never send sync messages or to our own account as sealed sender
|
||||
if (syncMessage || isMe(conversationAttrs)) {
|
||||
return {
|
||||
|
@ -93,54 +94,77 @@ export async function getSendOptions(
|
|||
};
|
||||
}
|
||||
|
||||
const { accessKey, sealedSender } = conversationAttrs;
|
||||
const { e164, serviceId } = conversationAttrs;
|
||||
|
||||
const senderCertificate =
|
||||
await getSenderCertificateForDirectConversation(conversationAttrs);
|
||||
|
||||
let identifierData: SendIdentifierData | null = null;
|
||||
// If we've never fetched user's profile, we default to what we have
|
||||
if (sealedSender === SEALED_SENDER.UNKNOWN || story) {
|
||||
const identifierData = {
|
||||
identifierData = {
|
||||
accessKey:
|
||||
accessKey ||
|
||||
(story
|
||||
? Bytes.toBase64(getZeroes(16))
|
||||
: Bytes.toBase64(getRandomBytes(16))),
|
||||
senderCertificate,
|
||||
};
|
||||
return {
|
||||
sendMetadata: {
|
||||
...(e164 ? { [e164]: identifierData } : {}),
|
||||
...(serviceId ? { [serviceId]: identifierData } : {}),
|
||||
},
|
||||
groupSendToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (sealedSender === SEALED_SENDER.DISABLED) {
|
||||
return {
|
||||
sendMetadata: undefined,
|
||||
if (serviceId != null && groupId != null) {
|
||||
const { state: groupSendEndorsementState, didRefreshGroupState } =
|
||||
await maybeCreateGroupSendEndorsementState(
|
||||
groupId,
|
||||
alreadyRefreshedGroupState
|
||||
);
|
||||
|
||||
if (
|
||||
groupSendEndorsementState != null &&
|
||||
groupSendEndorsementState.hasMember(serviceId)
|
||||
) {
|
||||
const token = groupSendEndorsementState.buildToken(
|
||||
new Set([serviceId])
|
||||
);
|
||||
if (token != null) {
|
||||
identifierData = {
|
||||
accessKey: null,
|
||||
senderCertificate,
|
||||
groupSendToken: token,
|
||||
};
|
||||
}
|
||||
} else if (didRefreshGroupState && !alreadyRefreshedGroupState) {
|
||||
return getSendOptions(conversationAttrs, options, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
identifierData = {
|
||||
accessKey:
|
||||
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
||||
? accessKey
|
||||
: Bytes.toBase64(getRandomBytes(16)),
|
||||
senderCertificate,
|
||||
groupSendToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
const identifierData = {
|
||||
accessKey:
|
||||
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
||||
? accessKey
|
||||
: Bytes.toBase64(getRandomBytes(16)),
|
||||
senderCertificate,
|
||||
};
|
||||
|
||||
return {
|
||||
sendMetadata: {
|
||||
let sendMetadata: SendMetadataType = {};
|
||||
if (identifierData != null) {
|
||||
sendMetadata = {
|
||||
...(e164 ? { [e164]: identifierData } : {}),
|
||||
...(serviceId ? { [serviceId]: identifierData } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return { sendMetadata };
|
||||
}
|
||||
|
||||
function getSenderCertificateForDirectConversation(
|
||||
async function getSenderCertificateForDirectConversation(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): Promise<undefined | SerializedCertificateType> {
|
||||
): Promise<SerializedCertificateType | null> {
|
||||
if (!isDirectConversation(conversationAttrs)) {
|
||||
throw new Error(
|
||||
'getSenderCertificateForDirectConversation should only be called for direct conversations'
|
||||
|
@ -154,5 +178,5 @@ function getSenderCertificateForDirectConversation(
|
|||
certificateMode = SenderCertificateMode.WithoutE164;
|
||||
}
|
||||
|
||||
return senderCertificateService.get(certificateMode);
|
||||
return (await senderCertificateService.get(certificateMode)) ?? null;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
GroupSendEndorsementsResponse,
|
||||
ServerPublicParams,
|
||||
} from './zkgroup';
|
||||
import type { AciString, ServiceIdString } from '../types/ServiceId';
|
||||
import type { ServiceIdString } from '../types/ServiceId';
|
||||
import { fromAciObject } from '../types/ServiceId';
|
||||
import * as log from '../logging/log';
|
||||
import type { GroupV2MemberType } from '../model-types';
|
||||
|
@ -28,6 +28,9 @@ import * as Errors from '../types/errors';
|
|||
import { isTestOrMockEnvironment } from '../environment';
|
||||
import { isAlpha } from './version';
|
||||
import { parseStrict } from './schemas';
|
||||
import { DataReader } from '../sql/Client';
|
||||
import { maybeUpdateGroup } from '../groups';
|
||||
import { isGroupV2 } from './whatTypeOfConversation';
|
||||
|
||||
export function decodeGroupSendEndorsementResponse({
|
||||
groupId,
|
||||
|
@ -136,12 +139,13 @@ export function isValidGroupSendEndorsementsExpiration(
|
|||
|
||||
export class GroupSendEndorsementState {
|
||||
#combinedEndorsement: GroupSendCombinedEndorsementRecord;
|
||||
#otherMemberEndorsements = new Map<
|
||||
#memberEndorsements = new Map<
|
||||
ServiceIdString,
|
||||
GroupSendMemberEndorsementRecord
|
||||
>();
|
||||
#otherMemberEndorsementsAcis = new Set<AciString>();
|
||||
#memberEndorsementsAcis = new Set<ServiceIdString>();
|
||||
#groupSecretParamsBase64: string;
|
||||
#ourAci: ServiceIdString;
|
||||
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
|
||||
|
||||
constructor(
|
||||
|
@ -150,13 +154,10 @@ export class GroupSendEndorsementState {
|
|||
) {
|
||||
this.#combinedEndorsement = data.combinedEndorsement;
|
||||
this.#groupSecretParamsBase64 = groupSecretParamsBase64;
|
||||
|
||||
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||
this.#ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||
for (const endorsement of data.memberEndorsements) {
|
||||
if (endorsement.memberAci !== ourAci) {
|
||||
this.#otherMemberEndorsements.set(endorsement.memberAci, endorsement);
|
||||
this.#otherMemberEndorsementsAcis.add(endorsement.memberAci);
|
||||
}
|
||||
this.#memberEndorsements.set(endorsement.memberAci, endorsement);
|
||||
this.#memberEndorsementsAcis.add(endorsement.memberAci);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,10 +172,10 @@ export class GroupSendEndorsementState {
|
|||
}
|
||||
|
||||
hasMember(serviceId: ServiceIdString): boolean {
|
||||
return this.#otherMemberEndorsements.has(serviceId);
|
||||
return this.#memberEndorsements.has(serviceId);
|
||||
}
|
||||
|
||||
#toEndorsement(contents: Uint8Array) {
|
||||
#toEndorsement(contents: Uint8Array): GroupSendEndorsement {
|
||||
let endorsement = this.#endorsementCache.get(contents);
|
||||
if (endorsement == null) {
|
||||
endorsement = new GroupSendEndorsement(Buffer.from(contents));
|
||||
|
@ -183,73 +184,7 @@ export class GroupSendEndorsementState {
|
|||
return endorsement;
|
||||
}
|
||||
|
||||
// Strategy 1: Faster when we're sending to most of the group members
|
||||
// `combined.byRemoving(combine(difference(members, sends)))`
|
||||
#subtractMemberEndorsements(
|
||||
difference: Set<ServiceIdString>
|
||||
): GroupSendEndorsement {
|
||||
const toRemove: Array<GroupSendEndorsement> = [];
|
||||
|
||||
for (const serviceId of difference) {
|
||||
const memberEndorsement = this.#otherMemberEndorsements.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.#otherMemberEndorsements.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 otherMemberCount = this.#otherMemberEndorsements.size;
|
||||
const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${otherMemberCount})`;
|
||||
|
||||
const missing = serviceIds.difference(this.#otherMemberEndorsementsAcis);
|
||||
if (missing.size !== 0) {
|
||||
throw new Error(
|
||||
`Attempted to build token with memberAcis we don't have endorsements for (${logServiceIds(missing)})`
|
||||
);
|
||||
}
|
||||
|
||||
const difference = this.#otherMemberEndorsementsAcis.difference(serviceIds);
|
||||
log.info(
|
||||
`buildToken: Endorsements without sends ${difference.size}: ${logServiceIds(difference)})`
|
||||
);
|
||||
|
||||
let endorsement: GroupSendEndorsement;
|
||||
if (difference.size === 0) {
|
||||
log.info(`${logId}: combinedEndorsement`);
|
||||
endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement);
|
||||
} else if (difference.size < otherMemberCount / 2) {
|
||||
log.info(`${logId}: subtractMemberEndorsements`);
|
||||
endorsement = this.#subtractMemberEndorsements(difference);
|
||||
} else {
|
||||
log.info(`${logId}: combineMemberEndorsements`);
|
||||
endorsement = this.#combineMemberEndorsements(serviceIds);
|
||||
}
|
||||
|
||||
#toToken(endorsement: GroupSendEndorsement): GroupSendToken {
|
||||
const groupSecretParams = new GroupSecretParams(
|
||||
Buffer.from(this.#groupSecretParamsBase64, 'base64')
|
||||
);
|
||||
|
@ -264,6 +199,105 @@ export class GroupSendEndorsementState {
|
|||
const fullToken = endorsement.toFullToken(groupSecretParams, expiration);
|
||||
return toGroupSendToken(fullToken.serialize());
|
||||
}
|
||||
|
||||
#getCombinedEndorsement(includesOurs: boolean) {
|
||||
const endorsement = this.#toEndorsement(
|
||||
this.#combinedEndorsement.endorsement
|
||||
);
|
||||
if (!includesOurs) {
|
||||
return endorsement;
|
||||
}
|
||||
return GroupSendEndorsement.combine([
|
||||
endorsement,
|
||||
this.#getMemberEndorsement(this.#ourAci),
|
||||
]);
|
||||
}
|
||||
|
||||
#getMemberEndorsement(serviceId: ServiceIdString) {
|
||||
const memberEndorsement = this.#memberEndorsements.get(serviceId);
|
||||
strictAssert(
|
||||
memberEndorsement,
|
||||
'subtractMemberEndorsements: Missing endorsement'
|
||||
);
|
||||
return this.#toEndorsement(memberEndorsement.endorsement);
|
||||
}
|
||||
|
||||
// Strategy 1: Faster when we're sending to most of the group members
|
||||
// `combined.byRemoving(combine(difference(members, sends)))`
|
||||
#subtractMemberEndorsements(
|
||||
otherMembersServiceIds: Set<ServiceIdString>,
|
||||
includesOurs: boolean
|
||||
): GroupSendEndorsement {
|
||||
strictAssert(
|
||||
!otherMembersServiceIds.has(this.#ourAci),
|
||||
'subtractMemberEndorsements: Cannot subtract our own aci from the combined endorsement'
|
||||
);
|
||||
return this.#getCombinedEndorsement(includesOurs).byRemoving(
|
||||
this.#combineMemberEndorsements(otherMembersServiceIds)
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy 2: Faster when we're not sending to most of the group members
|
||||
// `combine(sends)`
|
||||
#combineMemberEndorsements(
|
||||
serviceIds: Set<ServiceIdString>
|
||||
): GroupSendEndorsement {
|
||||
return GroupSendEndorsement.combine(
|
||||
Array.from(serviceIds, serviceId => {
|
||||
return this.#getMemberEndorsement(serviceId);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#buildToken(serviceIds: Set<ServiceIdString>): GroupSendEndorsement {
|
||||
const sendCount = serviceIds.size;
|
||||
const memberCount = this.#memberEndorsements.size;
|
||||
const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${memberCount})`;
|
||||
|
||||
// Fast path sending to one person
|
||||
if (serviceIds.size === 1) {
|
||||
log.info(`${logId}: using single member endorsement`);
|
||||
const [serviceId] = serviceIds;
|
||||
return this.#getMemberEndorsement(serviceId);
|
||||
}
|
||||
|
||||
const missing = serviceIds.difference(this.#memberEndorsementsAcis);
|
||||
if (missing.size !== 0) {
|
||||
throw new Error(
|
||||
`${logId}: Attempted to build token with memberAcis we don't have endorsements for (${logServiceIds(missing)})`
|
||||
);
|
||||
}
|
||||
|
||||
const difference = this.#memberEndorsementsAcis.difference(serviceIds);
|
||||
log.info(
|
||||
`${logId}: Endorsements without sends ${difference.size}: ${logServiceIds(difference)}`
|
||||
);
|
||||
|
||||
const otherMembers = new Set(difference);
|
||||
const includesOurs = otherMembers.delete(this.#ourAci);
|
||||
|
||||
if (otherMembers.size === 0) {
|
||||
log.info(`${logId}: using combined endorsement`);
|
||||
return this.#getCombinedEndorsement(includesOurs);
|
||||
}
|
||||
|
||||
if (otherMembers.size < memberCount / 2) {
|
||||
log.info(`${logId}: subtracting missing members`);
|
||||
return this.#subtractMemberEndorsements(otherMembers, includesOurs);
|
||||
}
|
||||
|
||||
log.info(`${logId}: combining all members`);
|
||||
return this.#combineMemberEndorsements(serviceIds);
|
||||
}
|
||||
|
||||
buildToken(serviceIds: Set<ServiceIdString>): GroupSendToken | null {
|
||||
try {
|
||||
return this.#toToken(this.#buildToken(new Set(serviceIds)));
|
||||
} catch (error) {
|
||||
onFailedToSendWithEndorsements(error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function onFailedToSendWithEndorsements(error: Error): void {
|
||||
|
@ -278,3 +312,58 @@ export function onFailedToSendWithEndorsements(error: Error): void {
|
|||
}
|
||||
devDebugger();
|
||||
}
|
||||
|
||||
type MaybeCreateGroupSendEndorsementStateResult =
|
||||
| { state: GroupSendEndorsementState; didRefreshGroupState: false }
|
||||
| { state: null; didRefreshGroupState: boolean };
|
||||
|
||||
export async function maybeCreateGroupSendEndorsementState(
|
||||
groupId: string,
|
||||
alreadyRefreshedGroupState: boolean
|
||||
): Promise<MaybeCreateGroupSendEndorsementStateResult> {
|
||||
const conversation = window.ConversationController.get(groupId);
|
||||
strictAssert(
|
||||
conversation != null,
|
||||
'maybeCreateGroupSendEndorsementState: Convertion not found'
|
||||
);
|
||||
|
||||
const logId = `maybeCreateGroupSendEndorsementState/${conversation.idForLogging()}`;
|
||||
|
||||
strictAssert(
|
||||
isGroupV2(conversation.attributes),
|
||||
`${logId}: Conversation is not groupV2`
|
||||
);
|
||||
|
||||
const data = await DataReader.getGroupSendEndorsementsData(groupId);
|
||||
if (data == null) {
|
||||
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||
if (conversation.isMember(ourAci)) {
|
||||
onFailedToSendWithEndorsements(
|
||||
new Error(`${logId}: Missing all endorsements for group`)
|
||||
);
|
||||
}
|
||||
return { state: null, didRefreshGroupState: false };
|
||||
}
|
||||
|
||||
const groupSecretParamsBase64 = conversation.get('secretParams');
|
||||
strictAssert(groupSecretParamsBase64, `${logId}: Must have secret params`);
|
||||
|
||||
const groupSendEndorsementState = new GroupSendEndorsementState(
|
||||
data,
|
||||
groupSecretParamsBase64
|
||||
);
|
||||
|
||||
if (
|
||||
groupSendEndorsementState != null &&
|
||||
!groupSendEndorsementState.isSafeExpirationRange() &&
|
||||
!alreadyRefreshedGroupState
|
||||
) {
|
||||
log.info(
|
||||
`${logId}: Endorsements close to expiration (${groupSendEndorsementState.getExpiration().getTime()}, ${Date.now()}), refreshing group`
|
||||
);
|
||||
await maybeUpdateGroup({ conversation });
|
||||
return { state: null, didRefreshGroupState: true };
|
||||
}
|
||||
|
||||
return { state: groupSendEndorsementState, didRefreshGroupState: false };
|
||||
}
|
||||
|
|
85
ts/util/mapEmplace.ts
Normal file
85
ts/util/mapEmplace.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { RequireAtLeastOne } from 'type-fest';
|
||||
|
||||
export type MapKey<T extends Map<unknown, unknown>> =
|
||||
T extends Map<infer Key, unknown> ? Key : never;
|
||||
|
||||
export type MapValue<T extends Map<unknown, unknown>> =
|
||||
T extends Map<unknown, infer Value> ? Value : never;
|
||||
|
||||
export type MapEmplaceOptions<T extends Map<unknown, unknown>> =
|
||||
RequireAtLeastOne<{
|
||||
insert?: (key: MapKey<T>, map: T) => MapValue<T>;
|
||||
update?: (existing: MapValue<T>, key: MapKey<T>, map: T) => MapValue<T>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Lightweight polyfill of the `Map.prototype.emplace` TC39 Proposal:
|
||||
* @see https://github.com/tc39/proposal-upsert
|
||||
*
|
||||
* @throws If no `insert()` is provided and key is not present
|
||||
*
|
||||
* @example Get or Insert:
|
||||
* ```ts
|
||||
* let pagesByBook = new Map<BookId, Map<PageId, Page>>()
|
||||
* for (let page of pages) {
|
||||
* let bookPages = mapEmplace(pagesByBook, page.bookId, {
|
||||
* insert: () => new Map(),
|
||||
* })
|
||||
* bookPages.set(page.id, page)
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example Get+Update or Insert:
|
||||
* ```ts
|
||||
* let unreadPages = new Map<BookId, number>()
|
||||
* for (let page of pages) {
|
||||
* if (page.readAt == null) {
|
||||
* mapEmplace(unreadPages, page.bookId, {
|
||||
* insert: () => 1,
|
||||
* update: (prev) => prev + 1,
|
||||
* })
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example Get+Update or Throw
|
||||
* ```ts
|
||||
* let PagesCache = new Map<PageId, Page>()
|
||||
*
|
||||
* function onPageReadEvent(pageId: PageId, readAt: number) {
|
||||
* if (PagesCache.has(pageId)) {
|
||||
* mapEmplace(PagesCache, pageId, {
|
||||
* update(page) {
|
||||
* return { ...page, readAt }
|
||||
* },
|
||||
* })
|
||||
* } else {
|
||||
* // save for later
|
||||
* onEarlyPageReadEvent(pageId, readAt)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function mapEmplace<T extends Map<unknown, unknown>>(
|
||||
map: T,
|
||||
key: MapKey<T>,
|
||||
options: MapEmplaceOptions<T>
|
||||
): MapValue<T> {
|
||||
if (map.has(key)) {
|
||||
let value = map.get(key) as MapValue<T>;
|
||||
if (options.update != null) {
|
||||
value = options.update(value, key, map);
|
||||
map.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (options.insert != null) {
|
||||
const value = options.insert(key, map);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
throw new Error('Key was not present in map, and insert() was not provided');
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { chunk } from 'lodash';
|
||||
import { chunk, map } from 'lodash';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import type { Receipt } from '../types/Receipt';
|
||||
import { ReceiptType } from '../types/Receipt';
|
||||
|
@ -9,8 +9,9 @@ import { getSendOptions } from './getSendOptions';
|
|||
import { handleMessageSend } from './handleMessageSend';
|
||||
import { isConversationAccepted } from './isConversationAccepted';
|
||||
import { isConversationUnregistered } from './isConversationUnregistered';
|
||||
import { map } from './iterables';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import { mapEmplace } from './mapEmplace';
|
||||
|
||||
const CHUNK_SIZE = 100;
|
||||
|
||||
|
@ -57,47 +58,53 @@ export async function sendReceipts({
|
|||
|
||||
log.info(`Starting receipt send of type ${type}`);
|
||||
|
||||
const receiptsBySenderId: Map<string, Array<Receipt>> = receipts.reduce(
|
||||
(result, receipt) => {
|
||||
const { senderE164, senderAci } = receipt;
|
||||
if (!senderE164 && !senderAci) {
|
||||
log.error('no sender E164 or Service Id. Skipping this receipt');
|
||||
return result;
|
||||
}
|
||||
type ConversationSenderReceiptGroup = {
|
||||
conversationId: string;
|
||||
sender: ConversationModel;
|
||||
receipts: Array<Receipt>;
|
||||
};
|
||||
const groupsByConversation = new Map<
|
||||
string,
|
||||
Map<string, ConversationSenderReceiptGroup>
|
||||
>();
|
||||
|
||||
const sender = window.ConversationController.lookupOrCreate({
|
||||
e164: senderE164,
|
||||
serviceId: senderAci,
|
||||
reason: 'sendReceipts',
|
||||
});
|
||||
if (!sender) {
|
||||
throw new Error(
|
||||
'no conversation found with that E164/Service Id. Cannot send this receipt'
|
||||
);
|
||||
}
|
||||
const allGroups = new Set<ConversationSenderReceiptGroup>();
|
||||
|
||||
const existingGroup = result.get(sender.id);
|
||||
if (existingGroup) {
|
||||
existingGroup.push(receipt);
|
||||
} else {
|
||||
result.set(sender.id, [receipt]);
|
||||
}
|
||||
for (const receipt of receipts) {
|
||||
const { senderE164, senderAci, conversationId } = receipt;
|
||||
if (!senderE164 && !senderAci) {
|
||||
log.error('no sender E164 or Service Id. Skipping this receipt');
|
||||
continue;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
new Map()
|
||||
);
|
||||
const sender = window.ConversationController.lookupOrCreate({
|
||||
e164: senderE164,
|
||||
serviceId: senderAci,
|
||||
reason: 'sendReceipts',
|
||||
});
|
||||
|
||||
if (!sender) {
|
||||
throw new Error(
|
||||
'no conversation found with that E164/Service Id. Cannot send this receipt'
|
||||
);
|
||||
}
|
||||
|
||||
const groupsBySender = mapEmplace(groupsByConversation, conversationId, {
|
||||
insert: () => new Map(),
|
||||
});
|
||||
const group = mapEmplace(groupsBySender, sender.id, {
|
||||
insert: () => ({ conversationId, sender, receipts: [] }),
|
||||
});
|
||||
|
||||
allGroups.add(group);
|
||||
group.receipts.push(receipt);
|
||||
}
|
||||
|
||||
await window.ConversationController.load();
|
||||
|
||||
await Promise.all(
|
||||
map(receiptsBySenderId, async ([senderId, receiptsForSender]) => {
|
||||
const sender = window.ConversationController.get(senderId);
|
||||
if (!sender) {
|
||||
throw new Error(
|
||||
'despite having a conversation ID, no conversation was found'
|
||||
);
|
||||
}
|
||||
Array.from(allGroups.values(), async group => {
|
||||
const { conversationId, sender, receipts: receiptsForSender } = group;
|
||||
|
||||
if (!isConversationAccepted(sender.attributes)) {
|
||||
log.info(
|
||||
|
@ -120,7 +127,12 @@ export async function sendReceipts({
|
|||
|
||||
log.info(`Sending receipt of type ${type} to ${sender.idForLogging()}`);
|
||||
|
||||
const sendOptions = await getSendOptions(sender.attributes);
|
||||
const conversation = window.ConversationController.get(conversationId);
|
||||
const groupId = conversation?.get('groupId');
|
||||
|
||||
const sendOptions = await getSendOptions(sender.attributes, {
|
||||
groupId,
|
||||
});
|
||||
|
||||
const batches = chunk(receiptsForSender, CHUNK_SIZE);
|
||||
await Promise.all(
|
||||
|
|
|
@ -23,7 +23,7 @@ import {
|
|||
import { Address } from '../types/Address';
|
||||
import { QualifiedAddress } from '../types/QualifiedAddress';
|
||||
import * as Errors from '../types/errors';
|
||||
import { DataWriter, DataReader } from '../sql/Client';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
import { getValue } from '../RemoteConfig';
|
||||
import type { ServiceIdString } from '../types/ServiceId';
|
||||
import { ServiceIdKind } from '../types/ServiceId';
|
||||
|
@ -66,11 +66,11 @@ import { strictAssert } from './assert';
|
|||
import * as log from '../logging/log';
|
||||
import { GLOBAL_ZONE } from '../SignalProtocolStore';
|
||||
import { waitForAll } from './waitForAll';
|
||||
import type { GroupSendEndorsementState } from './groupSendEndorsements';
|
||||
import {
|
||||
GroupSendEndorsementState,
|
||||
maybeCreateGroupSendEndorsementState,
|
||||
onFailedToSendWithEndorsements,
|
||||
} from './groupSendEndorsements';
|
||||
import { maybeUpdateGroup } from '../groups';
|
||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||
import { isAciString } from './isAciString';
|
||||
import { safeParseStrict, safeParseUnknown } from './schemas';
|
||||
|
@ -213,11 +213,11 @@ export async function sendContentMessageToGroup(
|
|||
|
||||
if (sendTarget.isValid()) {
|
||||
try {
|
||||
return await sendToGroupViaSenderKey(
|
||||
options,
|
||||
0,
|
||||
'init (sendContentMessageToGroup)'
|
||||
);
|
||||
return await sendToGroupViaSenderKey(options, {
|
||||
count: 0,
|
||||
didRefreshGroupState: false,
|
||||
reason: 'init (sendContentMessageToGroup)',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
|
@ -259,11 +259,27 @@ export async function sendContentMessageToGroup(
|
|||
|
||||
// The Primary Sender Key workflow
|
||||
|
||||
type SendRecursion = {
|
||||
count: number;
|
||||
didRefreshGroupState: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export async function sendToGroupViaSenderKey(
|
||||
options: SendToGroupOptions,
|
||||
recursionCount: number,
|
||||
recursionReason: string
|
||||
recursion: SendRecursion
|
||||
): Promise<CallbackResultType> {
|
||||
function startOver(
|
||||
reason: string,
|
||||
didRefreshGroupState = recursion.didRefreshGroupState
|
||||
) {
|
||||
return sendToGroupViaSenderKey(options, {
|
||||
count: recursion.count + 1,
|
||||
didRefreshGroupState,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
contentHint,
|
||||
contentMessage,
|
||||
|
@ -282,12 +298,12 @@ export async function sendToGroupViaSenderKey(
|
|||
|
||||
const logId = sendTarget.idForLogging();
|
||||
log.info(
|
||||
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}, reason: ${recursionReason}...`
|
||||
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursion.count}, reason: ${recursion.reason}...`
|
||||
);
|
||||
|
||||
if (recursionCount > MAX_RECURSION) {
|
||||
if (recursion.count > MAX_RECURSION) {
|
||||
throw new Error(
|
||||
`sendToGroupViaSenderKey/${logId}: Too much recursion! Count is at ${recursionCount}`
|
||||
`sendToGroupViaSenderKey/${logId}: Too much recursion! Count is at ${recursion.count}`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -328,11 +344,7 @@ export async function sendToGroupViaSenderKey(
|
|||
});
|
||||
|
||||
// Restart here because we updated senderKeyInfo
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'Added missing sender key info'
|
||||
);
|
||||
return startOver('Added missing sender key info');
|
||||
}
|
||||
|
||||
const EXPIRE_DURATION = getSenderKeyExpireDuration();
|
||||
|
@ -344,11 +356,7 @@ export async function sendToGroupViaSenderKey(
|
|||
await resetSenderKey(sendTarget);
|
||||
|
||||
// Restart here because we updated senderKeyInfo
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'sender key info expired'
|
||||
);
|
||||
return startOver('sender key info expired');
|
||||
}
|
||||
|
||||
// 2. Fetch all devices we believe we'll be sending to
|
||||
|
@ -356,49 +364,20 @@ export async function sendToGroupViaSenderKey(
|
|||
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) {
|
||||
strictAssert(conversation, 'Must have conversation for endorsements');
|
||||
|
||||
const data = await DataReader.getGroupSendEndorsementsData(groupId);
|
||||
if (data == null) {
|
||||
if (conversation.isMember(ourAci)) {
|
||||
onFailedToSendWithEndorsements(
|
||||
new Error(
|
||||
`sendToGroupViaSenderKey/${logId}: Missing all endorsements for group`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.info(
|
||||
`sendToGroupViaSenderKey/${logId}: Loaded endorsements for ${data.memberEndorsements.length} members`
|
||||
if (groupId != null && !story) {
|
||||
const { state, didRefreshGroupState } =
|
||||
await maybeCreateGroupSendEndorsementState(
|
||||
groupId,
|
||||
recursion.didRefreshGroupState
|
||||
);
|
||||
const groupSecretParamsBase64 = conversation.get('secretParams');
|
||||
strictAssert(groupSecretParamsBase64, 'Must have secret params');
|
||||
groupSendEndorsementState = new GroupSendEndorsementState(
|
||||
data,
|
||||
groupSecretParamsBase64
|
||||
if (state != null) {
|
||||
groupSendEndorsementState = state;
|
||||
} else if (didRefreshGroupState) {
|
||||
return startOver(
|
||||
'group send endorsements outside expiration range',
|
||||
true
|
||||
);
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -412,11 +391,7 @@ export async function sendToGroupViaSenderKey(
|
|||
await fetchKeysForServiceIds(emptyServiceIds, groupSendEndorsementState);
|
||||
|
||||
// Restart here to capture devices for accounts we just started sessions with
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'fetched prekey bundles'
|
||||
);
|
||||
return startOver('fetched prekey bundles');
|
||||
}
|
||||
|
||||
const { memberDevices, distributionId, createdAtDate } = senderKeyInfo;
|
||||
|
@ -478,11 +453,7 @@ export async function sendToGroupViaSenderKey(
|
|||
|
||||
// Restart here to start over; empty memberDevices means we'll send distribution
|
||||
// message to everyone.
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'removed members in send target'
|
||||
);
|
||||
return startOver('removed members in send target');
|
||||
}
|
||||
|
||||
// 8. If there are new members or new devices in the group, we need to ensure that they
|
||||
|
@ -533,11 +504,7 @@ export async function sendToGroupViaSenderKey(
|
|||
|
||||
// Restart here because we might have discovered new or dropped devices as part of
|
||||
// distributing our sender key.
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'sent skdm to new members'
|
||||
);
|
||||
return startOver('sent skdm to new members');
|
||||
}
|
||||
|
||||
// 9. Update memberDevices with removals which didn't require a reset.
|
||||
|
@ -572,17 +539,12 @@ export async function sendToGroupViaSenderKey(
|
|||
senderKeyRecipientsWithDevices[serviceId].push(id);
|
||||
});
|
||||
|
||||
let groupSendToken: GroupSendToken | undefined;
|
||||
let accessKeys: Buffer | undefined;
|
||||
let groupSendToken: GroupSendToken | null = null;
|
||||
let accessKeys: Buffer | null = null;
|
||||
if (groupSendEndorsementState != null) {
|
||||
strictAssert(conversation, 'Must have conversation for endorsements');
|
||||
try {
|
||||
groupSendToken = groupSendEndorsementState.buildToken(
|
||||
new Set(senderKeyRecipients)
|
||||
);
|
||||
} catch (error) {
|
||||
onFailedToSendWithEndorsements(error);
|
||||
}
|
||||
groupSendToken = groupSendEndorsementState.buildToken(
|
||||
new Set(senderKeyRecipients)
|
||||
);
|
||||
} else {
|
||||
accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
|
||||
}
|
||||
|
@ -656,22 +618,14 @@ export async function sendToGroupViaSenderKey(
|
|||
await handle409Response(sendTarget, groupSendEndorsementState, error);
|
||||
|
||||
// Restart here to capture the right set of devices for our next send.
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'error: expired or missing devices'
|
||||
);
|
||||
return startOver('error: expired or missing devices');
|
||||
}
|
||||
if (error.code === ERROR_STALE_DEVICES) {
|
||||
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 + 1,
|
||||
'error: stale devices'
|
||||
);
|
||||
return startOver('error: stale devices');
|
||||
}
|
||||
if (
|
||||
error instanceof LibSignalErrorBase &&
|
||||
|
@ -689,11 +643,7 @@ export async function sendToGroupViaSenderKey(
|
|||
await DataWriter.updateConversation(brokenAccount.attributes);
|
||||
|
||||
// Now that we've eliminate this problematic account, we can try the send again.
|
||||
return sendToGroupViaSenderKey(
|
||||
options,
|
||||
recursionCount + 1,
|
||||
'error: invalid registration id'
|
||||
);
|
||||
return startOver('error: invalid registration id');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue