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",
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||||
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|
|
@ -3183,9 +3183,13 @@ export async function startApp(): Promise<void> {
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
case FETCH_LATEST_ENUM.LOCAL_PROFILE: {
|
case FETCH_LATEST_ENUM.LOCAL_PROFILE: {
|
||||||
log.info('onFetchLatestSync: fetching latest local profile');
|
log.info('onFetchLatestSync: fetching latest local profile');
|
||||||
const ourAci = window.textsecure.storage.user.getAci();
|
const ourAci = window.textsecure.storage.user.getAci() ?? null;
|
||||||
const ourE164 = window.textsecure.storage.user.getNumber();
|
const ourE164 = window.textsecure.storage.user.getNumber() ?? null;
|
||||||
await getProfile(ourAci, ourE164);
|
await getProfile({
|
||||||
|
serviceId: ourAci,
|
||||||
|
e164: ourE164,
|
||||||
|
groupId: null,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
||||||
|
|
28
ts/groups.ts
28
ts/groups.ts
|
@ -100,6 +100,7 @@ import {
|
||||||
decodeGroupSendEndorsementResponse,
|
decodeGroupSendEndorsementResponse,
|
||||||
isValidGroupSendEndorsementsExpiration,
|
isValidGroupSendEndorsementsExpiration,
|
||||||
} from './util/groupSendEndorsements';
|
} from './util/groupSendEndorsements';
|
||||||
|
import { getProfile } from './util/getProfile';
|
||||||
import { generateMessageId } from './util/generateMessageId';
|
import { generateMessageId } from './util/generateMessageId';
|
||||||
|
|
||||||
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
|
@ -3020,11 +3021,10 @@ export async function waitThenMaybeUpdateGroup(
|
||||||
{ viaFirstStorageSync = false } = {}
|
{ viaFirstStorageSync = false } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { conversation } = options;
|
const { conversation } = options;
|
||||||
|
const logId = `waitThenMaybeUpdateGroup(${conversation.idForLogging()})`;
|
||||||
|
|
||||||
if (conversation.isBlocked()) {
|
if (conversation.isBlocked()) {
|
||||||
log.info(
|
log.info(`${logId}: Conversation is blocked, returning early`);
|
||||||
`waitThenMaybeUpdateGroup: Group ${conversation.idForLogging()} is blocked, returning early`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3039,12 +3039,13 @@ export async function waitThenMaybeUpdateGroup(
|
||||||
) {
|
) {
|
||||||
const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date.now();
|
const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date.now();
|
||||||
log.info(
|
log.info(
|
||||||
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: group update ` +
|
`${logId}: group update was fetched recently, skipping for ${waitTime}ms`
|
||||||
`was fetched recently, skipping for ${waitTime}ms`
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info(`${logId}: group update was not fetched recently, queuing update`);
|
||||||
|
|
||||||
// Then wait to process all outstanding messages for this conversation
|
// Then wait to process all outstanding messages for this conversation
|
||||||
await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
|
await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -3054,7 +3055,7 @@ export async function waitThenMaybeUpdateGroup(
|
||||||
conversation.lastSuccessfulGroupFetch = Date.now();
|
conversation.lastSuccessfulGroupFetch = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: maybeUpdateGroup failure:`,
|
`${logId}: maybeUpdateGroup failure:`,
|
||||||
Errors.toLogFormat(error)
|
Errors.toLogFormat(error)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3072,7 +3073,7 @@ export async function maybeUpdateGroup(
|
||||||
}: MaybeUpdatePropsType,
|
}: MaybeUpdatePropsType,
|
||||||
{ viaFirstStorageSync = false } = {}
|
{ viaFirstStorageSync = false } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const logId = conversation.idForLogging();
|
const logId = `maybeUpdateGroup/${conversation.idForLogging()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||||
|
@ -3091,10 +3092,7 @@ export async function maybeUpdateGroup(
|
||||||
{ viaFirstStorageSync }
|
{ viaFirstStorageSync }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(`${logId}: Failed to update group:`, Errors.toLogFormat(error));
|
||||||
`maybeUpdateGroup/${logId}: Failed to update group:`,
|
|
||||||
Errors.toLogFormat(error)
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3205,7 +3203,13 @@ async function updateGroup(
|
||||||
);
|
);
|
||||||
|
|
||||||
profileFetches = Promise.all(
|
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(),
|
conversationId: z.string(),
|
||||||
protoBase64: z.string(),
|
protoBase64: z.string(),
|
||||||
urgent: z.boolean(),
|
urgent: z.boolean(),
|
||||||
// These two are group-only
|
// These are group-only
|
||||||
recipients: z.array(serviceIdSchema).optional(),
|
recipients: z.array(serviceIdSchema).optional(),
|
||||||
isPartialSend: z.boolean().optional(),
|
isPartialSend: z.boolean().optional(),
|
||||||
|
groupId: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type CallingMessageJobData = z.infer<typeof callingMessageJobDataSchema>;
|
export type CallingMessageJobData = z.infer<typeof callingMessageJobDataSchema>;
|
||||||
|
|
||||||
|
|
|
@ -61,6 +61,7 @@ export async function sendCallingMessage(
|
||||||
urgent,
|
urgent,
|
||||||
recipients: jobRecipients,
|
recipients: jobRecipients,
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
|
groupId,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const recipients = getValidRecipients(
|
const recipients = getValidRecipients(
|
||||||
|
@ -85,7 +86,9 @@ export async function sendCallingMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendType = 'callingMessage';
|
const sendType = 'callingMessage';
|
||||||
const sendOptions = await getSendOptions(conversation.attributes);
|
const sendOptions = await getSendOptions(conversation.attributes, {
|
||||||
|
groupId,
|
||||||
|
});
|
||||||
|
|
||||||
const callingMessage = Proto.CallingMessage.decode(
|
const callingMessage = Proto.CallingMessage.decode(
|
||||||
Bytes.fromBase64(protoBase64)
|
Bytes.fromBase64(protoBase64)
|
||||||
|
|
|
@ -1171,6 +1171,7 @@ export class ConversationModel extends window.Backbone
|
||||||
options: { force?: boolean } = {}
|
options: { force?: boolean } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isGroupV2(this.attributes)) {
|
if (!isGroupV2(this.attributes)) {
|
||||||
|
log.info('fetchLatestGroupV2Data: Not groupV2');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4872,9 +4873,17 @@ export class ConversationModel extends window.Backbone
|
||||||
const conversations =
|
const conversations =
|
||||||
this.getMembers() as unknown as Array<ConversationModel>;
|
this.getMembers() as unknown as Array<ConversationModel>;
|
||||||
|
|
||||||
|
const groupId = isGroupV2(this.attributes)
|
||||||
|
? (this.get('groupId') ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
conversations.map(conversation =>
|
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,
|
avatarUrl: undefined | null | string,
|
||||||
decryptionKey: Uint8Array
|
decryptionKey: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -4964,6 +4973,11 @@ export class ConversationModel extends window.Backbone
|
||||||
reason,
|
reason,
|
||||||
}: { viaStorageServiceSync?: boolean; reason: string }
|
}: { viaStorageServiceSync?: boolean; reason: string }
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
strictAssert(
|
||||||
|
profileKey == null || profileKey.length > 0,
|
||||||
|
'setProfileKey: Profile key cannot be an empty string'
|
||||||
|
);
|
||||||
|
|
||||||
const oldProfileKey = this.get('profileKey');
|
const oldProfileKey = this.get('profileKey');
|
||||||
|
|
||||||
// profileKey is a string so we can compare it directly
|
// profileKey is a string so we can compare it directly
|
||||||
|
|
|
@ -136,7 +136,11 @@ export async function routineProfileRefresh({
|
||||||
|
|
||||||
totalCount += 1;
|
totalCount += 1;
|
||||||
try {
|
try {
|
||||||
await getProfileFn(conversation.getServiceId(), conversation.get('e164'));
|
await getProfileFn({
|
||||||
|
serviceId: conversation.getServiceId() ?? null,
|
||||||
|
e164: conversation.get('e164') ?? null,
|
||||||
|
groupId: null,
|
||||||
|
});
|
||||||
log.info(
|
log.info(
|
||||||
`${logId}: refreshed profile for ${conversation.idForLogging()}`
|
`${logId}: refreshed profile for ${conversation.idForLogging()}`
|
||||||
);
|
);
|
||||||
|
|
|
@ -2676,6 +2676,7 @@ export class CallingClass {
|
||||||
urgent,
|
urgent,
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
recipients,
|
recipients,
|
||||||
|
groupId,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info('handleSendCallMessageToGroup() completed successfully');
|
log.info('handleSendCallMessageToGroup() completed successfully');
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assertDev } from '../util/assert';
|
import { assertDev, strictAssert } from '../util/assert';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
|
||||||
import type { StorageInterface } from '../types/Storage.d';
|
import type { StorageInterface } from '../types/Storage.d';
|
||||||
|
@ -40,11 +40,16 @@ export class OurProfileKeyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(newValue: undefined | Uint8Array): Promise<void> {
|
async set(newValue: undefined | Uint8Array): Promise<void> {
|
||||||
log.info('Our profile key service: updating profile key');
|
|
||||||
assertDev(this.storage, 'OurProfileKeyService was not initialized');
|
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);
|
await this.storage.put('profileKey', newValue);
|
||||||
} else {
|
} else {
|
||||||
|
log.info('Our profile key service: removing profile key');
|
||||||
await this.storage.remove('profileKey');
|
await this.storage.remove('profileKey');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 {
|
import type {
|
||||||
GetProfileOptionsType,
|
ClientZkProfileOperations,
|
||||||
GetProfileUnauthOptionsType,
|
ProfileKeyCredentialRequestContext,
|
||||||
CapabilitiesType,
|
} from '@signalapp/libsignal-client/zkgroup';
|
||||||
} from '../textsecure/WebAPI';
|
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 MessageSender from '../textsecure/SendMessage';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import { DataWriter } from '../sql/Client';
|
import { DataWriter } from '../sql/Client';
|
||||||
|
@ -38,6 +37,12 @@ import { HTTPError } from '../textsecure/Errors';
|
||||||
import { Address } from '../types/Address';
|
import { Address } from '../types/Address';
|
||||||
import { QualifiedAddress } from '../types/QualifiedAddress';
|
import { QualifiedAddress } from '../types/QualifiedAddress';
|
||||||
import { trimForDisplay, verifyAccessKey, decryptProfile } from '../Crypto';
|
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 = {
|
type JobType = {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
|
@ -81,7 +86,10 @@ export class ProfileService {
|
||||||
log.info('Profile Service initialized');
|
log.info('Profile Service initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(conversationId: string): Promise<void> {
|
public async get(
|
||||||
|
conversationId: string,
|
||||||
|
groupId: string | null
|
||||||
|
): Promise<void> {
|
||||||
const preCheckConversation =
|
const preCheckConversation =
|
||||||
window.ConversationController.get(conversationId);
|
window.ConversationController.get(conversationId);
|
||||||
if (!preCheckConversation) {
|
if (!preCheckConversation) {
|
||||||
|
@ -122,7 +130,7 @@ export class ProfileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.fetchProfile(conversation);
|
await this.fetchProfile(conversation, groupId);
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
@ -220,388 +228,561 @@ export class ProfileService {
|
||||||
|
|
||||||
export const profileService = new ProfileService();
|
export const profileService = new ProfileService();
|
||||||
|
|
||||||
async function doGetProfile(c: ConversationModel): Promise<void> {
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
const idForLogging = c.idForLogging();
|
namespace ProfileFetchOptions {
|
||||||
const { messaging } = window.textsecure;
|
type Base = ReadonlyDeep<{
|
||||||
strictAssert(
|
request: {
|
||||||
messaging,
|
userLanguages: ReadonlyArray<string>;
|
||||||
'getProfile: window.textsecure.messaging not available'
|
};
|
||||||
);
|
}>;
|
||||||
|
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;
|
export type Unauth =
|
||||||
strictAssert(
|
// versioned (unauth)
|
||||||
typeof updatesUrl === 'string',
|
| (Base & WithVersioned & WithUnauthAccessKey)
|
||||||
'getProfile: expected updatesUrl to be a defined string'
|
// unversioned (unauth)
|
||||||
);
|
| (Base & WithUnversioned & WithUnauthAccessKey)
|
||||||
|
| (Base & WithUnversioned & WithUnauthGroupSendToken);
|
||||||
|
|
||||||
const clientZkProfileCipher = getClientZkProfileOperations(
|
export type Auth =
|
||||||
window.getServerPublicParams()
|
// 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(
|
const userLanguages = getUserLanguages(
|
||||||
window.SignalContext.getPreferredSystemLocales(),
|
window.SignalContext.getPreferredSystemLocales(),
|
||||||
window.SignalContext.getResolvedMessagesLocale()
|
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) {
|
if (profileKey) {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
profileKeyVersion != null && accessKey != null,
|
profileKeyVersion != null && accessKey != null,
|
||||||
'profileKeyVersion and accessKey are derived from profileKey'
|
`${logId}: profileKeyVersion and accessKey are derived from profileKey`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!c.hasProfileKeyCredentialExpired()) {
|
if (!conversation.hasProfileKeyCredentialExpired()) {
|
||||||
getProfileOptions = {
|
log.info(`${logId}: using unexpired profile key credential`);
|
||||||
accessKey,
|
return {
|
||||||
profileKeyVersion,
|
profileKey,
|
||||||
userLanguages,
|
profileCredentialRequestContext: null,
|
||||||
};
|
request: {
|
||||||
} else {
|
userLanguages,
|
||||||
log.info(
|
accessKey,
|
||||||
'getProfile: generating profile key credential request for ' +
|
groupSendToken: null,
|
||||||
`conversation ${idForLogging}`
|
profileKeyVersion,
|
||||||
);
|
profileKeyCredentialRequest: null,
|
||||||
|
},
|
||||||
let profileKeyCredentialRequestHex: undefined | string;
|
|
||||||
({
|
|
||||||
requestHex: profileKeyCredentialRequestHex,
|
|
||||||
context: profileCredentialRequestContext,
|
|
||||||
} = generateProfileKeyCredentialRequest(
|
|
||||||
clientZkProfileCipher,
|
|
||||||
serviceId,
|
|
||||||
profileKey
|
|
||||||
));
|
|
||||||
|
|
||||||
getProfileOptions = {
|
|
||||||
accessKey,
|
|
||||||
userLanguages,
|
|
||||||
profileKeyVersion,
|
|
||||||
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
strictAssert(
|
log.info(`${logId}: generating profile key credential request`);
|
||||||
!accessKey,
|
const result = generateProfileKeyCredentialRequest(
|
||||||
'accessKey have to be absent because there is no profileKey'
|
clientZkProfileCipher,
|
||||||
|
serviceId,
|
||||||
|
profileKey
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lastProfile?.profileKeyVersion) {
|
return {
|
||||||
getProfileOptions = {
|
profileKey,
|
||||||
|
profileCredentialRequestContext: result.context,
|
||||||
|
request: {
|
||||||
userLanguages,
|
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,
|
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);
|
// Fallback to auth
|
||||||
log.info(
|
return {
|
||||||
`getProfile: getting ${isVersioned ? 'versioned' : 'unversioned'} ` +
|
profileKey: null,
|
||||||
`profile for conversation ${idForLogging}`
|
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 {
|
try {
|
||||||
if (getProfileOptions.accessKey) {
|
if (request.accessKey != null || request.groupSendToken != null) {
|
||||||
try {
|
profile = await messaging.server.getProfileUnauth(serviceId, request);
|
||||||
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;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
strictAssert(
|
||||||
// We won't get the credential, but lets either fetch:
|
!isMe(c.attributes),
|
||||||
// - a versioned profile using last known profileKeyVersion
|
`${logId}: Should never fetch own profile on auth connection`
|
||||||
// - 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}`
|
|
||||||
);
|
);
|
||||||
c.set({
|
profile = await messaging.server.getProfile(serviceId, request);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof HTTPError)) {
|
log.error(`${logId}: Failed to fetch profile`, Errors.toLogFormat(error));
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (error.code) {
|
if (error instanceof HTTPError) {
|
||||||
case 401:
|
// Unauthorized/Forbidden
|
||||||
case 403:
|
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 (
|
if (
|
||||||
c.get('sealedSender') === SEALED_SENDER.ENABLED ||
|
sealedSender === SEALED_SENDER.ENABLED ||
|
||||||
c.get('sealedSender') === SEALED_SENDER.UNRESTRICTED
|
sealedSender === SEALED_SENDER.UNRESTRICTED
|
||||||
) {
|
) {
|
||||||
log.warn(
|
|
||||||
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, removing profileKey`
|
|
||||||
);
|
|
||||||
if (!isMe(c.attributes)) {
|
if (!isMe(c.attributes)) {
|
||||||
|
log.warn(
|
||||||
|
`${logId}: Got ${error.code} when using accessKey, removing profileKey`
|
||||||
|
);
|
||||||
await c.setProfileKey(undefined, {
|
await c.setProfileKey(undefined, {
|
||||||
reason: 'doGetProfile/accessKey/401+403',
|
reason: 'doGetProfile/accessKey/401+403',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
} else if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
||||||
if (c.get('sealedSender') === SEALED_SENDER.UNKNOWN) {
|
|
||||||
log.warn(
|
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);
|
c.set('sealedSender', SEALED_SENDER.DISABLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Is it safe to ignore these errors?
|
||||||
return;
|
return;
|
||||||
default:
|
}
|
||||||
log.warn(
|
|
||||||
'getProfile failure:',
|
// Not Found
|
||||||
idForLogging,
|
if (error.code === 404) {
|
||||||
isNumber(error.code)
|
log.info(`${logId}: Profile not found`);
|
||||||
? `code: ${error.code}`
|
|
||||||
: Errors.toLogFormat(error)
|
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;
|
// Step #: Save profile `badges` to conversation and update redux
|
||||||
const decryptionKey = decryptionKeyString
|
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
|
||||||
? Bytes.fromBase64(decryptionKeyString)
|
if (badges.length) {
|
||||||
: undefined;
|
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;
|
let isSuccessfullyDecrypted = true;
|
||||||
if (profile.name) {
|
|
||||||
if (decryptionKey) {
|
// Step #: Save profile `name` to conversation
|
||||||
|
if (isFieldDefined(profile.name)) {
|
||||||
|
if (requestDecryptionKey != null) {
|
||||||
try {
|
try {
|
||||||
await c.setEncryptedProfileName(profile.name, decryptionKey);
|
// Note: Writes to DB and saves message
|
||||||
|
await c.setEncryptedProfileName(profile.name, requestDecryptionKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(
|
log.warn(
|
||||||
'getProfile decryption failure:',
|
`${logId}: Failed to decrypt profile name`,
|
||||||
idForLogging,
|
|
||||||
Errors.toLogFormat(error)
|
Errors.toLogFormat(error)
|
||||||
);
|
);
|
||||||
isSuccessfullyDecrypted = false;
|
isSuccessfullyDecrypted = false;
|
||||||
await c.set({
|
c.set({
|
||||||
profileName: undefined,
|
profileName: undefined,
|
||||||
profileFamilyName: undefined,
|
profileFamilyName: undefined,
|
||||||
});
|
});
|
||||||
|
@ -615,19 +796,22 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (decryptionKey) {
|
if (requestDecryptionKey != null) {
|
||||||
await c.setProfileAvatar(profile.avatar, decryptionKey);
|
// Note: Fetches avatar
|
||||||
|
await c.setAndMaybeFetchProfileAvatar(
|
||||||
|
profile.avatar,
|
||||||
|
requestDecryptionKey
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HTTPError) {
|
if (error instanceof HTTPError) {
|
||||||
|
// Forbidden/Not Found
|
||||||
if (error.code === 403 || error.code === 404) {
|
if (error.code === 403 || error.code === 404) {
|
||||||
log.warn(
|
log.warn(`${logId}: Profile avatar is missing (${error.code})`);
|
||||||
`getProfile: profile avatar is missing for conversation ${idForLogging}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn(
|
log.warn(
|
||||||
`getProfile: failed to decrypt avatar for conversation ${idForLogging}`,
|
`${logId}: Failed to decrypt profile avatar`,
|
||||||
Errors.toLogFormat(error)
|
Errors.toLogFormat(error)
|
||||||
);
|
);
|
||||||
isSuccessfullyDecrypted = false;
|
isSuccessfullyDecrypted = false;
|
||||||
|
@ -639,12 +823,12 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
|
||||||
// After we successfully decrypted - update lastProfile property
|
// After we successfully decrypted - update lastProfile property
|
||||||
if (
|
if (
|
||||||
isSuccessfullyDecrypted &&
|
isSuccessfullyDecrypted &&
|
||||||
profileKey &&
|
options.profileKey &&
|
||||||
getProfileOptions.profileKeyVersion
|
request.profileKeyVersion
|
||||||
) {
|
) {
|
||||||
await c.updateLastProfile(lastProfile, {
|
await c.updateLastProfile(lastProfile, {
|
||||||
profileKey,
|
profileKey: options.profileKey,
|
||||||
profileKeyVersion: getProfileOptions.profileKeyVersion,
|
profileKeyVersion: request.profileKeyVersion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1395,7 +1395,7 @@ export async function mergeAccountRecord(
|
||||||
: PhoneNumberDiscoverability.Discoverable;
|
: PhoneNumberDiscoverability.Discoverable;
|
||||||
await window.storage.put('phoneNumberDiscoverability', discoverability);
|
await window.storage.put('phoneNumberDiscoverability', discoverability);
|
||||||
|
|
||||||
if (profileKey) {
|
if (profileKey && profileKey.byteLength > 0) {
|
||||||
void ourProfileKeyService.set(profileKey);
|
void ourProfileKeyService.set(profileKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1657,14 +1657,14 @@ export async function mergeAccountRecord(
|
||||||
});
|
});
|
||||||
|
|
||||||
let needsProfileFetch = false;
|
let needsProfileFetch = false;
|
||||||
if (profileKey && profileKey.length > 0) {
|
if (profileKey && profileKey.byteLength > 0) {
|
||||||
needsProfileFetch = await conversation.setProfileKey(
|
needsProfileFetch = await conversation.setProfileKey(
|
||||||
Bytes.toBase64(profileKey),
|
Bytes.toBase64(profileKey),
|
||||||
{ viaStorageServiceSync: true, reason: 'mergeAccountRecord' }
|
{ viaStorageServiceSync: true, reason: 'mergeAccountRecord' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatarUrl = dropNull(accountRecord.avatarUrl);
|
const avatarUrl = dropNull(accountRecord.avatarUrl);
|
||||||
await conversation.setProfileAvatar(avatarUrl, profileKey);
|
await conversation.setAndMaybeFetchProfileAvatar(avatarUrl, profileKey);
|
||||||
await window.storage.put('avatarUrl', avatarUrl);
|
await window.storage.put('avatarUrl', avatarUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -127,7 +127,11 @@ class UsernameIntegrityService {
|
||||||
private async checkPhoneNumberSharing(): Promise<void> {
|
private async checkPhoneNumberSharing(): Promise<void> {
|
||||||
const me = window.ConversationController.getOurConversationOrThrow();
|
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();
|
const localValue = isSharingPhoneNumberWithEverybody();
|
||||||
|
|
|
@ -34,7 +34,11 @@ export async function writeProfile(
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return;
|
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
|
// Encrypt the profile data, update profile, and if needed upload the avatar
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -4385,6 +4385,10 @@ function onConversationOpened(
|
||||||
throw new Error('onConversationOpened: Conversation not found');
|
throw new Error('onConversationOpened: Conversation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logId = `onConversationOpened(${conversation.idForLogging()})`;
|
||||||
|
|
||||||
|
log.info(`${logId}: Updating newly opened conversation state`);
|
||||||
|
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
const message = await __DEPRECATED$getMessageById(messageId);
|
const message = await __DEPRECATED$getMessageById(messageId);
|
||||||
|
|
||||||
|
@ -4393,7 +4397,7 @@ function onConversationOpened(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn(`onOpened: Did not find message ${messageId}`);
|
log.warn(`${logId}: Did not find message ${messageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { retryPlaceholders } = window.Signal.Services;
|
const { retryPlaceholders } = window.Signal.Services;
|
||||||
|
@ -4427,12 +4431,12 @@ function onConversationOpened(
|
||||||
promises.push(conversation.fetchLatestGroupV2Data());
|
promises.push(conversation.fetchLatestGroupV2Data());
|
||||||
strictAssert(
|
strictAssert(
|
||||||
conversation.throttledMaybeMigrateV1Group !== undefined,
|
conversation.throttledMaybeMigrateV1Group !== undefined,
|
||||||
'Conversation model should be initialized'
|
`${logId}: Conversation model should be initialized`
|
||||||
);
|
);
|
||||||
promises.push(conversation.throttledMaybeMigrateV1Group());
|
promises.push(conversation.throttledMaybeMigrateV1Group());
|
||||||
strictAssert(
|
strictAssert(
|
||||||
conversation.throttledFetchSMSOnlyUUID !== undefined,
|
conversation.throttledFetchSMSOnlyUUID !== undefined,
|
||||||
'Conversation model should be initialized'
|
`${logId}: Conversation model should be initialized`
|
||||||
);
|
);
|
||||||
promises.push(conversation.throttledFetchSMSOnlyUUID());
|
promises.push(conversation.throttledFetchSMSOnlyUUID());
|
||||||
|
|
||||||
|
@ -4443,7 +4447,7 @@ function onConversationOpened(
|
||||||
) {
|
) {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
conversation.throttledGetProfiles !== undefined,
|
conversation.throttledGetProfiles !== undefined,
|
||||||
'Conversation model should be initialized'
|
`${logId}: Conversation model should be initialized`
|
||||||
);
|
);
|
||||||
await conversation.throttledGetProfiles().catch(() => {
|
await conversation.throttledGetProfiles().catch(() => {
|
||||||
/* nothing to do here; logging already happened */
|
/* 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 { DAY, HOUR, MINUTE, MONTH } from '../util/durations';
|
||||||
|
|
||||||
import { routineProfileRefresh } from '../routineProfileRefresh';
|
import { routineProfileRefresh } from '../routineProfileRefresh';
|
||||||
|
import type { getProfile } from '../util/getProfile';
|
||||||
|
|
||||||
describe('routineProfileRefresh', () => {
|
describe('routineProfileRefresh', () => {
|
||||||
let sinonSandbox: sinon.SinonSandbox;
|
let sinonSandbox: sinon.SinonSandbox;
|
||||||
let getProfileFn: sinon.SinonStub;
|
let getProfileFn: sinon.SinonStub<
|
||||||
|
Parameters<typeof getProfile>,
|
||||||
|
ReturnType<typeof getProfile>
|
||||||
|
>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sinonSandbox = sinon.createSandbox();
|
sinonSandbox = sinon.createSandbox();
|
||||||
|
@ -111,16 +115,16 @@ describe('routineProfileRefresh', () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: conversation1.getServiceId() ?? null,
|
||||||
conversation1.getServiceId(),
|
e164: conversation1.get('e164') ?? null,
|
||||||
conversation1.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: conversation2.getServiceId() ?? null,
|
||||||
conversation2.getServiceId(),
|
e164: conversation2.get('e164') ?? null,
|
||||||
conversation2.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips unregistered conversations and those fetched in the last three days', async () => {
|
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.calledOnce(getProfileFn);
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: normal.getServiceId() ?? null,
|
||||||
normal.getServiceId(),
|
e164: normal.get('e164') ?? null,
|
||||||
normal.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: recentlyFetched.getServiceId() ?? null,
|
||||||
recentlyFetched.getServiceId(),
|
e164: recentlyFetched.get('e164') ?? null,
|
||||||
recentlyFetched.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: unregisteredAndStale.getServiceId() ?? null,
|
||||||
unregisteredAndStale.getServiceId(),
|
e164: unregisteredAndStale.get('e164') ?? null,
|
||||||
unregisteredAndStale.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips your own conversation', async () => {
|
it('skips your own conversation', async () => {
|
||||||
|
@ -170,16 +174,16 @@ describe('routineProfileRefresh', () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: notMe.getServiceId() ?? null,
|
||||||
notMe.getServiceId(),
|
e164: notMe.get('e164') ?? null,
|
||||||
notMe.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: me.getServiceId() ?? null,
|
||||||
me.getServiceId(),
|
e164: me.get('e164') ?? null,
|
||||||
me.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes your own conversation if profileKeyCredential is expired', async () => {
|
it('includes your own conversation if profileKeyCredential is expired', async () => {
|
||||||
|
@ -198,12 +202,16 @@ describe('routineProfileRefresh', () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: notMe.getServiceId() ?? null,
|
||||||
notMe.getServiceId(),
|
e164: notMe.get('e164') ?? null,
|
||||||
notMe.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.calledWith(getProfileFn, me.getServiceId(), me.get('e164'));
|
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 () => {
|
it('skips conversations that were refreshed in last three days', async () => {
|
||||||
|
@ -236,31 +244,31 @@ describe('routineProfileRefresh', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
sinon.assert.calledTwice(getProfileFn);
|
sinon.assert.calledTwice(getProfileFn);
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: neverRefreshed.getServiceId() ?? null,
|
||||||
neverRefreshed.getServiceId(),
|
e164: neverRefreshed.get('e164') ?? null,
|
||||||
neverRefreshed.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: refreshedToday.getServiceId() ?? null,
|
||||||
refreshedToday.getServiceId(),
|
e164: refreshedToday.get('e164') ?? null,
|
||||||
refreshedToday.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: refreshedYesterday.getServiceId() ?? null,
|
||||||
refreshedYesterday.getServiceId(),
|
e164: refreshedYesterday.get('e164') ?? null,
|
||||||
refreshedYesterday.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: refreshedTwoDaysAgo.getServiceId() ?? null,
|
||||||
refreshedTwoDaysAgo.getServiceId(),
|
e164: refreshedTwoDaysAgo.get('e164') ?? null,
|
||||||
refreshedTwoDaysAgo.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: refreshedThreeDaysAgo.getServiceId() ?? null,
|
||||||
refreshedThreeDaysAgo.getServiceId(),
|
e164: refreshedThreeDaysAgo.get('e164') ?? null,
|
||||||
refreshedThreeDaysAgo.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only refreshes profiles for the 50 conversations with the oldest profileLastFetchedAt', async () => {
|
it('only refreshes profiles for the 50 conversations with the oldest profileLastFetchedAt', async () => {
|
||||||
|
@ -300,19 +308,19 @@ describe('routineProfileRefresh', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
[...normalConversations, ...neverFetched].forEach(conversation => {
|
[...normalConversations, ...neverFetched].forEach(conversation => {
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: conversation.getServiceId() ?? null,
|
||||||
conversation.getServiceId(),
|
e164: conversation.get('e164') ?? null,
|
||||||
conversation.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
[me, ...shouldNotBeIncluded].forEach(conversation => {
|
[me, ...shouldNotBeIncluded].forEach(conversation => {
|
||||||
sinon.assert.neverCalledWith(
|
sinon.assert.neverCalledWith(getProfileFn, {
|
||||||
getProfileFn,
|
serviceId: conversation.getServiceId() ?? null,
|
||||||
conversation.getServiceId(),
|
e164: conversation.get('e164') ?? null,
|
||||||
conversation.get('e164')
|
groupId: null,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -47,10 +47,10 @@ describe('util/profiles', () => {
|
||||||
};
|
};
|
||||||
const service = new ProfileService(getProfileWithLongDelay);
|
const service = new ProfileService(getProfileWithLongDelay);
|
||||||
|
|
||||||
const promise1 = service.get(SERVICE_ID_1);
|
const promise1 = service.get(SERVICE_ID_1, null);
|
||||||
const promise2 = service.get(SERVICE_ID_2);
|
const promise2 = service.get(SERVICE_ID_2, null);
|
||||||
const promise3 = service.get(SERVICE_ID_3);
|
const promise3 = service.get(SERVICE_ID_3, null);
|
||||||
const promise4 = service.get(SERVICE_ID_4);
|
const promise4 = service.get(SERVICE_ID_4, null);
|
||||||
|
|
||||||
service.clearAll('testing');
|
service.clearAll('testing');
|
||||||
|
|
||||||
|
@ -71,12 +71,12 @@ describe('util/profiles', () => {
|
||||||
const service = new ProfileService(getProfileWithIncrement);
|
const service = new ProfileService(getProfileWithIncrement);
|
||||||
|
|
||||||
// Queued and immediately started due to concurrency = 3
|
// Queued and immediately started due to concurrency = 3
|
||||||
drop(service.get(SERVICE_ID_1));
|
drop(service.get(SERVICE_ID_1, null));
|
||||||
drop(service.get(SERVICE_ID_2));
|
drop(service.get(SERVICE_ID_2, null));
|
||||||
drop(service.get(SERVICE_ID_3));
|
drop(service.get(SERVICE_ID_3, null));
|
||||||
|
|
||||||
// Queued but only run after paused queue restarts
|
// 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);
|
const pausePromise = service.pause(5);
|
||||||
|
|
||||||
|
@ -101,10 +101,10 @@ describe('util/profiles', () => {
|
||||||
const pausePromise = service.pause(5);
|
const pausePromise = service.pause(5);
|
||||||
|
|
||||||
// None of these are even queued
|
// None of these are even queued
|
||||||
const promise1 = service.get(SERVICE_ID_1);
|
const promise1 = service.get(SERVICE_ID_1, null);
|
||||||
const promise2 = service.get(SERVICE_ID_2);
|
const promise2 = service.get(SERVICE_ID_2, null);
|
||||||
const promise3 = service.get(SERVICE_ID_3);
|
const promise3 = service.get(SERVICE_ID_3, null);
|
||||||
const promise4 = service.get(SERVICE_ID_4);
|
const promise4 = service.get(SERVICE_ID_4, null);
|
||||||
|
|
||||||
await assert.isRejected(promise1, 'paused queue');
|
await assert.isRejected(promise1, 'paused queue');
|
||||||
await assert.isRejected(promise2, 'paused queue');
|
await assert.isRejected(promise2, 'paused queue');
|
||||||
|
@ -132,19 +132,19 @@ describe('util/profiles', () => {
|
||||||
const service = new ProfileService(getProfileWhichThrows);
|
const service = new ProfileService(getProfileWhichThrows);
|
||||||
|
|
||||||
// Queued and immediately started due to concurrency = 3
|
// Queued and immediately started due to concurrency = 3
|
||||||
const promise1 = service.get(SERVICE_ID_1);
|
const promise1 = service.get(SERVICE_ID_1, null);
|
||||||
const promise2 = service.get(SERVICE_ID_2);
|
const promise2 = service.get(SERVICE_ID_2, null);
|
||||||
const promise3 = service.get(SERVICE_ID_3);
|
const promise3 = service.get(SERVICE_ID_3, null);
|
||||||
|
|
||||||
// Never started, but queued
|
// 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');
|
assert.strictEqual(runCount, 3, 'before await');
|
||||||
|
|
||||||
await assert.isRejected(promise1, `fake ${code}`);
|
await assert.isRejected(promise1, `fake ${code}`);
|
||||||
|
|
||||||
// Never queued
|
// 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(promise2, 'job cancelled');
|
||||||
await assert.isRejected(promise3, 'job cancelled');
|
await assert.isRejected(promise3, 'job cancelled');
|
||||||
|
@ -168,19 +168,19 @@ describe('util/profiles', () => {
|
||||||
const service = new ProfileService(getProfileWhichThrows);
|
const service = new ProfileService(getProfileWhichThrows);
|
||||||
|
|
||||||
// Queued and immediately started due to concurrency = 3
|
// Queued and immediately started due to concurrency = 3
|
||||||
const promise1 = service.get(SERVICE_ID_1);
|
const promise1 = service.get(SERVICE_ID_1, null);
|
||||||
const promise2 = service.get(SERVICE_ID_2);
|
const promise2 = service.get(SERVICE_ID_2, null);
|
||||||
const promise3 = service.get(SERVICE_ID_3);
|
const promise3 = service.get(SERVICE_ID_3, null);
|
||||||
|
|
||||||
// Never started, but queued
|
// 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');
|
assert.strictEqual(runCount, 3, 'before await');
|
||||||
|
|
||||||
await assert.isRejected(promise1, 'fake -1');
|
await assert.isRejected(promise1, 'fake -1');
|
||||||
|
|
||||||
// Queued, because we aren't pausing
|
// 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(promise2, 'job cancelled');
|
||||||
await assert.isRejected(promise3, '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 { getKeysForServiceId } from './getKeysForServiceId';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||||
|
|
||||||
export const enum SenderCertificateMode {
|
export const enum SenderCertificateMode {
|
||||||
WithE164,
|
WithE164,
|
||||||
|
@ -306,13 +307,20 @@ export default class OutgoingMessage {
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
jsonData: ReadonlyArray<MessageType>,
|
jsonData: ReadonlyArray<MessageType>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
{ accessKey }: { accessKey?: string } = {}
|
{
|
||||||
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
|
}: {
|
||||||
|
accessKey: string | null;
|
||||||
|
groupSendToken: GroupSendToken | null;
|
||||||
|
} = { accessKey: null, groupSendToken: null }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let promise;
|
let promise;
|
||||||
|
|
||||||
if (accessKey) {
|
if (accessKey != null || groupSendToken != null) {
|
||||||
promise = this.server.sendMessagesUnauth(serviceId, jsonData, timestamp, {
|
promise = this.server.sendMessagesUnauth(serviceId, jsonData, timestamp, {
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
online: this.online,
|
online: this.online,
|
||||||
story: this.story,
|
story: this.story,
|
||||||
urgent: this.urgent,
|
urgent: this.urgent,
|
||||||
|
@ -393,7 +401,11 @@ export default class OutgoingMessage {
|
||||||
recurse?: boolean
|
recurse?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sendMetadata } = this;
|
const { sendMetadata } = this;
|
||||||
const { accessKey, senderCertificate } = sendMetadata?.[serviceId] || {};
|
const {
|
||||||
|
accessKey = null,
|
||||||
|
groupSendToken = null,
|
||||||
|
senderCertificate,
|
||||||
|
} = sendMetadata?.[serviceId] || {};
|
||||||
|
|
||||||
if (accessKey && !senderCertificate) {
|
if (accessKey && !senderCertificate) {
|
||||||
log.warn(
|
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
|
// We don't send to ourselves unless sealedSender is enabled
|
||||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
const ourNumber = window.textsecure.storage.user.getNumber();
|
||||||
|
@ -508,6 +522,7 @@ export default class OutgoingMessage {
|
||||||
if (sealedSender) {
|
if (sealedSender) {
|
||||||
return this.transmitMessage(serviceId, jsonData, this.timestamp, {
|
return this.transmitMessage(serviceId, jsonData, this.timestamp, {
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
}).then(
|
}).then(
|
||||||
() => {
|
() => {
|
||||||
this.recipients[serviceId] = deviceIds;
|
this.recipients[serviceId] = deviceIds;
|
||||||
|
|
|
@ -36,8 +36,6 @@ import {
|
||||||
import type {
|
import type {
|
||||||
ChallengeType,
|
ChallengeType,
|
||||||
GetGroupLogOptionsType,
|
GetGroupLogOptionsType,
|
||||||
GetProfileOptionsType,
|
|
||||||
GetProfileUnauthOptionsType,
|
|
||||||
GroupCredentialsType,
|
GroupCredentialsType,
|
||||||
GroupLogResponseType,
|
GroupLogResponseType,
|
||||||
ProxiedRequestOptionsType,
|
ProxiedRequestOptionsType,
|
||||||
|
@ -103,12 +101,22 @@ import {
|
||||||
getProtoForCallHistory,
|
getProtoForCallHistory,
|
||||||
} from '../util/callDisposition';
|
} from '../util/callDisposition';
|
||||||
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types';
|
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 = {
|
export type SendMetadataType = {
|
||||||
[serviceId: ServiceIdString]: {
|
[serviceId: ServiceIdString]: SendIdentifierData;
|
||||||
accessKey: string;
|
|
||||||
senderCertificate?: SerializedCertificateType;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SendOptionsType = {
|
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
|
// Note: instead of updating these functions, or adding new ones, remove these and go
|
||||||
// directly to window.textsecure.messaging.server.<function>
|
// 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']>> {
|
async getAvatar(path: string): Promise<ReturnType<WebAPIType['getAvatar']>> {
|
||||||
return this.server.getAvatar(path);
|
return this.server.getAvatar(path);
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,10 @@ import { isStagingServer } from '../util/isStagingServer';
|
||||||
import type { IWebSocketResource } from './WebsocketResources';
|
import type { IWebSocketResource } from './WebsocketResources';
|
||||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||||
import { parseUnknown, safeParseUnknown } from '../util/schemas';
|
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
|
// Note: this will break some code that expects to be able to use err.response when a
|
||||||
// web request fails, because it will force it to text. But it is very useful for
|
// web request fails, because it will force it to text. But it is very useful for
|
||||||
|
@ -91,7 +95,7 @@ function resolveLibsignalNetEnvironment(url: string): Net.Environment {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _createRedactor(
|
function _createRedactor(
|
||||||
...toReplace: ReadonlyArray<string | undefined>
|
...toReplace: ReadonlyArray<string | undefined | null>
|
||||||
): RedactUrl {
|
): RedactUrl {
|
||||||
// NOTE: It would be nice to remove this cast, but TypeScript doesn't support
|
// 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.
|
// it. However, there is [an issue][0] that discusses this in more detail.
|
||||||
|
@ -898,31 +902,6 @@ export type CdsLookupOptionsType = Readonly<{
|
||||||
useLibsignal?: boolean;
|
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<{
|
export type GetGroupCredentialsOptionsType = Readonly<{
|
||||||
startDayInMs: number;
|
startDayInMs: number;
|
||||||
endDayInMs: number;
|
endDayInMs: number;
|
||||||
|
@ -1311,14 +1290,14 @@ export type WebAPIType = {
|
||||||
}>;
|
}>;
|
||||||
getProfile: (
|
getProfile: (
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
options: GetProfileOptionsType
|
options: ProfileFetchAuthRequestOptions
|
||||||
) => Promise<ProfileType>;
|
) => Promise<ProfileType>;
|
||||||
getAccountForUsername: (
|
getAccountForUsername: (
|
||||||
options: GetAccountForUsernameOptionsType
|
options: GetAccountForUsernameOptionsType
|
||||||
) => Promise<GetAccountForUsernameResultType>;
|
) => Promise<GetAccountForUsernameResultType>;
|
||||||
getProfileUnauth: (
|
getProfileUnauth: (
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
options: GetProfileUnauthOptionsType
|
options: ProfileFetchUnauthRequestOptions
|
||||||
) => Promise<ProfileType>;
|
) => Promise<ProfileType>;
|
||||||
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
|
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
|
||||||
getSubscriptionConfiguration: (
|
getSubscriptionConfiguration: (
|
||||||
|
@ -1413,7 +1392,8 @@ export type WebAPIType = {
|
||||||
messageArray: ReadonlyArray<MessageType>,
|
messageArray: ReadonlyArray<MessageType>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: {
|
options: {
|
||||||
accessKey?: string;
|
accessKey: string | null;
|
||||||
|
groupSendToken: GroupSendToken | null;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
story?: boolean;
|
story?: boolean;
|
||||||
urgent?: boolean;
|
urgent?: boolean;
|
||||||
|
@ -1421,8 +1401,8 @@ export type WebAPIType = {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendWithSenderKey: (
|
sendWithSenderKey: (
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
accessKeys: Uint8Array | undefined,
|
accessKeys: Uint8Array | null,
|
||||||
groupSendToken: GroupSendToken | undefined,
|
groupSendToken: GroupSendToken | null,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: {
|
options: {
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
|
@ -2177,19 +2157,19 @@ export function initialize({
|
||||||
{
|
{
|
||||||
profileKeyVersion,
|
profileKeyVersion,
|
||||||
profileKeyCredentialRequest,
|
profileKeyCredentialRequest,
|
||||||
}: GetProfileCommonOptionsType
|
}: ProfileFetchAuthRequestOptions | ProfileFetchUnauthRequestOptions
|
||||||
) {
|
) {
|
||||||
let profileUrl = `/${serviceId}`;
|
let profileUrl = `/${serviceId}`;
|
||||||
if (profileKeyVersion !== undefined) {
|
if (profileKeyVersion != null) {
|
||||||
profileUrl += `/${profileKeyVersion}`;
|
profileUrl += `/${profileKeyVersion}`;
|
||||||
if (profileKeyCredentialRequest !== undefined) {
|
if (profileKeyCredentialRequest != null) {
|
||||||
profileUrl +=
|
profileUrl +=
|
||||||
`/${profileKeyCredentialRequest}` +
|
`/${profileKeyCredentialRequest}` +
|
||||||
'?credentialType=expiringProfileKey';
|
'?credentialType=expiringProfileKey';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
profileKeyCredentialRequest === undefined,
|
profileKeyCredentialRequest == null,
|
||||||
'getProfileUrl called without version, but with request'
|
'getProfileUrl called without version, but with request'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2199,7 +2179,7 @@ export function initialize({
|
||||||
|
|
||||||
async function getProfile(
|
async function getProfile(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
options: GetProfileOptionsType
|
options: ProfileFetchAuthRequestOptions
|
||||||
) {
|
) {
|
||||||
const { profileKeyVersion, profileKeyCredentialRequest, userLanguages } =
|
const { profileKeyVersion, profileKeyCredentialRequest, userLanguages } =
|
||||||
options;
|
options;
|
||||||
|
@ -2258,15 +2238,25 @@ export function initialize({
|
||||||
|
|
||||||
async function getProfileUnauth(
|
async function getProfileUnauth(
|
||||||
serviceId: ServiceIdString,
|
serviceId: ServiceIdString,
|
||||||
options: GetProfileUnauthOptionsType
|
options: ProfileFetchUnauthRequestOptions
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
profileKeyVersion,
|
profileKeyVersion,
|
||||||
profileKeyCredentialRequest,
|
profileKeyCredentialRequest,
|
||||||
userLanguages,
|
userLanguages,
|
||||||
} = options;
|
} = 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({
|
return (await _ajax({
|
||||||
call: 'profile',
|
call: 'profile',
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
|
@ -2276,8 +2266,8 @@ export function initialize({
|
||||||
},
|
},
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey,
|
accessKey: accessKey ?? undefined,
|
||||||
groupSendToken: undefined,
|
groupSendToken: groupSendToken ?? undefined,
|
||||||
redactUrl: _createRedactor(
|
redactUrl: _createRedactor(
|
||||||
serviceId,
|
serviceId,
|
||||||
profileKeyVersion,
|
profileKeyVersion,
|
||||||
|
@ -3273,11 +3263,13 @@ export function initialize({
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
{
|
{
|
||||||
accessKey,
|
accessKey,
|
||||||
|
groupSendToken,
|
||||||
online,
|
online,
|
||||||
urgent = true,
|
urgent = true,
|
||||||
story = false,
|
story = false,
|
||||||
}: {
|
}: {
|
||||||
accessKey?: string;
|
accessKey: string | null;
|
||||||
|
groupSendToken: GroupSendToken | null;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
story?: boolean;
|
story?: boolean;
|
||||||
urgent?: boolean;
|
urgent?: boolean;
|
||||||
|
@ -3297,8 +3289,8 @@ export function initialize({
|
||||||
jsonData,
|
jsonData,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey,
|
accessKey: accessKey ?? undefined,
|
||||||
groupSendToken: undefined,
|
groupSendToken: groupSendToken ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3334,8 +3326,8 @@ export function initialize({
|
||||||
|
|
||||||
async function sendWithSenderKey(
|
async function sendWithSenderKey(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
accessKeys: Uint8Array | undefined,
|
accessKeys: Uint8Array | null,
|
||||||
groupSendToken: GroupSendToken | undefined,
|
groupSendToken: GroupSendToken | null,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
{
|
{
|
||||||
online,
|
online,
|
||||||
|
@ -3360,7 +3352,7 @@ export function initialize({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
unauthenticated: true,
|
unauthenticated: true,
|
||||||
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
|
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
|
||||||
groupSendToken,
|
groupSendToken: groupSendToken ?? undefined,
|
||||||
});
|
});
|
||||||
const parseResult = safeParseUnknown(
|
const parseResult = safeParseUnknown(
|
||||||
multiRecipient200ResponseSchema,
|
multiRecipient200ResponseSchema,
|
||||||
|
|
|
@ -5,10 +5,15 @@ import * as log from '../logging/log';
|
||||||
import { profileService } from '../services/profiles';
|
import { profileService } from '../services/profiles';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
|
|
||||||
export async function getProfile(
|
export async function getProfile({
|
||||||
serviceId?: ServiceIdString,
|
serviceId,
|
||||||
e164?: string
|
e164,
|
||||||
): Promise<void> {
|
groupId,
|
||||||
|
}: {
|
||||||
|
serviceId: ServiceIdString | null;
|
||||||
|
e164: string | null;
|
||||||
|
groupId: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
const c = window.ConversationController.lookupOrCreate({
|
const c = window.ConversationController.lookupOrCreate({
|
||||||
serviceId,
|
serviceId,
|
||||||
e164,
|
e164,
|
||||||
|
@ -19,5 +24,5 @@ export async function getProfile(
|
||||||
return;
|
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 { ConversationAttributesType } from '../model-types.d';
|
||||||
import type {
|
import type {
|
||||||
|
SendIdentifierData,
|
||||||
SendMetadataType,
|
SendMetadataType,
|
||||||
SendOptionsType,
|
SendOptionsType,
|
||||||
} from '../textsecure/SendMessage';
|
} from '../textsecure/SendMessage';
|
||||||
|
@ -15,6 +16,7 @@ import { shouldSharePhoneNumberWith } from './phoneNumberSharingMode';
|
||||||
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
|
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
|
||||||
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
|
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
|
||||||
import { isNotNil } from './isNotNil';
|
import { isNotNil } from './isNotNil';
|
||||||
|
import { maybeCreateGroupSendEndorsementState } from './groupSendEndorsements';
|
||||||
|
|
||||||
const SEALED_SENDER = {
|
const SEALED_SENDER = {
|
||||||
UNKNOWN: 0,
|
UNKNOWN: 0,
|
||||||
|
@ -61,9 +63,10 @@ export async function getSendOptionsForRecipients(
|
||||||
|
|
||||||
export async function getSendOptions(
|
export async function getSendOptions(
|
||||||
conversationAttrs: ConversationAttributesType,
|
conversationAttrs: ConversationAttributesType,
|
||||||
options: { syncMessage?: boolean; story?: boolean } = {}
|
options: { syncMessage?: boolean; story?: boolean; groupId?: string } = {},
|
||||||
|
alreadyRefreshedGroupState = false
|
||||||
): Promise<SendOptionsType> {
|
): Promise<SendOptionsType> {
|
||||||
const { syncMessage, story } = options;
|
const { syncMessage, story, groupId } = options;
|
||||||
|
|
||||||
if (!isDirectConversation(conversationAttrs)) {
|
if (!isDirectConversation(conversationAttrs)) {
|
||||||
const contactCollection = getConversationMembers(conversationAttrs);
|
const contactCollection = getConversationMembers(conversationAttrs);
|
||||||
|
@ -84,8 +87,6 @@ export async function getSendOptions(
|
||||||
return { sendMetadata };
|
return { sendMetadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessKey, sealedSender } = conversationAttrs;
|
|
||||||
|
|
||||||
// We never send sync messages or to our own account as sealed sender
|
// We never send sync messages or to our own account as sealed sender
|
||||||
if (syncMessage || isMe(conversationAttrs)) {
|
if (syncMessage || isMe(conversationAttrs)) {
|
||||||
return {
|
return {
|
||||||
|
@ -93,54 +94,77 @@ export async function getSendOptions(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { accessKey, sealedSender } = conversationAttrs;
|
||||||
const { e164, serviceId } = conversationAttrs;
|
const { e164, serviceId } = conversationAttrs;
|
||||||
|
|
||||||
const senderCertificate =
|
const senderCertificate =
|
||||||
await getSenderCertificateForDirectConversation(conversationAttrs);
|
await getSenderCertificateForDirectConversation(conversationAttrs);
|
||||||
|
|
||||||
|
let identifierData: SendIdentifierData | null = null;
|
||||||
// If we've never fetched user's profile, we default to what we have
|
// If we've never fetched user's profile, we default to what we have
|
||||||
if (sealedSender === SEALED_SENDER.UNKNOWN || story) {
|
if (sealedSender === SEALED_SENDER.UNKNOWN || story) {
|
||||||
const identifierData = {
|
identifierData = {
|
||||||
accessKey:
|
accessKey:
|
||||||
accessKey ||
|
accessKey ||
|
||||||
(story
|
(story
|
||||||
? Bytes.toBase64(getZeroes(16))
|
? Bytes.toBase64(getZeroes(16))
|
||||||
: Bytes.toBase64(getRandomBytes(16))),
|
: Bytes.toBase64(getRandomBytes(16))),
|
||||||
senderCertificate,
|
senderCertificate,
|
||||||
};
|
groupSendToken: null,
|
||||||
return {
|
|
||||||
sendMetadata: {
|
|
||||||
...(e164 ? { [e164]: identifierData } : {}),
|
|
||||||
...(serviceId ? { [serviceId]: identifierData } : {}),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sealedSender === SEALED_SENDER.DISABLED) {
|
if (sealedSender === SEALED_SENDER.DISABLED) {
|
||||||
return {
|
if (serviceId != null && groupId != null) {
|
||||||
sendMetadata: undefined,
|
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 = {
|
let sendMetadata: SendMetadataType = {};
|
||||||
accessKey:
|
if (identifierData != null) {
|
||||||
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
sendMetadata = {
|
||||||
? accessKey
|
|
||||||
: Bytes.toBase64(getRandomBytes(16)),
|
|
||||||
senderCertificate,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
sendMetadata: {
|
|
||||||
...(e164 ? { [e164]: identifierData } : {}),
|
...(e164 ? { [e164]: identifierData } : {}),
|
||||||
...(serviceId ? { [serviceId]: identifierData } : {}),
|
...(serviceId ? { [serviceId]: identifierData } : {}),
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
|
return { sendMetadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSenderCertificateForDirectConversation(
|
async function getSenderCertificateForDirectConversation(
|
||||||
conversationAttrs: ConversationAttributesType
|
conversationAttrs: ConversationAttributesType
|
||||||
): Promise<undefined | SerializedCertificateType> {
|
): Promise<SerializedCertificateType | null> {
|
||||||
if (!isDirectConversation(conversationAttrs)) {
|
if (!isDirectConversation(conversationAttrs)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'getSenderCertificateForDirectConversation should only be called for direct conversations'
|
'getSenderCertificateForDirectConversation should only be called for direct conversations'
|
||||||
|
@ -154,5 +178,5 @@ function getSenderCertificateForDirectConversation(
|
||||||
certificateMode = SenderCertificateMode.WithoutE164;
|
certificateMode = SenderCertificateMode.WithoutE164;
|
||||||
}
|
}
|
||||||
|
|
||||||
return senderCertificateService.get(certificateMode);
|
return (await senderCertificateService.get(certificateMode)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {
|
||||||
GroupSendEndorsementsResponse,
|
GroupSendEndorsementsResponse,
|
||||||
ServerPublicParams,
|
ServerPublicParams,
|
||||||
} from './zkgroup';
|
} from './zkgroup';
|
||||||
import type { AciString, ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import { fromAciObject } from '../types/ServiceId';
|
import { fromAciObject } from '../types/ServiceId';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import type { GroupV2MemberType } from '../model-types';
|
import type { GroupV2MemberType } from '../model-types';
|
||||||
|
@ -28,6 +28,9 @@ import * as Errors from '../types/errors';
|
||||||
import { isTestOrMockEnvironment } from '../environment';
|
import { isTestOrMockEnvironment } from '../environment';
|
||||||
import { isAlpha } from './version';
|
import { isAlpha } from './version';
|
||||||
import { parseStrict } from './schemas';
|
import { parseStrict } from './schemas';
|
||||||
|
import { DataReader } from '../sql/Client';
|
||||||
|
import { maybeUpdateGroup } from '../groups';
|
||||||
|
import { isGroupV2 } from './whatTypeOfConversation';
|
||||||
|
|
||||||
export function decodeGroupSendEndorsementResponse({
|
export function decodeGroupSendEndorsementResponse({
|
||||||
groupId,
|
groupId,
|
||||||
|
@ -136,12 +139,13 @@ export function isValidGroupSendEndorsementsExpiration(
|
||||||
|
|
||||||
export class GroupSendEndorsementState {
|
export class GroupSendEndorsementState {
|
||||||
#combinedEndorsement: GroupSendCombinedEndorsementRecord;
|
#combinedEndorsement: GroupSendCombinedEndorsementRecord;
|
||||||
#otherMemberEndorsements = new Map<
|
#memberEndorsements = new Map<
|
||||||
ServiceIdString,
|
ServiceIdString,
|
||||||
GroupSendMemberEndorsementRecord
|
GroupSendMemberEndorsementRecord
|
||||||
>();
|
>();
|
||||||
#otherMemberEndorsementsAcis = new Set<AciString>();
|
#memberEndorsementsAcis = new Set<ServiceIdString>();
|
||||||
#groupSecretParamsBase64: string;
|
#groupSecretParamsBase64: string;
|
||||||
|
#ourAci: ServiceIdString;
|
||||||
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
|
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -150,13 +154,10 @@ export class GroupSendEndorsementState {
|
||||||
) {
|
) {
|
||||||
this.#combinedEndorsement = data.combinedEndorsement;
|
this.#combinedEndorsement = data.combinedEndorsement;
|
||||||
this.#groupSecretParamsBase64 = groupSecretParamsBase64;
|
this.#groupSecretParamsBase64 = groupSecretParamsBase64;
|
||||||
|
this.#ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||||
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
|
||||||
for (const endorsement of data.memberEndorsements) {
|
for (const endorsement of data.memberEndorsements) {
|
||||||
if (endorsement.memberAci !== ourAci) {
|
this.#memberEndorsements.set(endorsement.memberAci, endorsement);
|
||||||
this.#otherMemberEndorsements.set(endorsement.memberAci, endorsement);
|
this.#memberEndorsementsAcis.add(endorsement.memberAci);
|
||||||
this.#otherMemberEndorsementsAcis.add(endorsement.memberAci);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,10 +172,10 @@ export class GroupSendEndorsementState {
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMember(serviceId: ServiceIdString): boolean {
|
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);
|
let endorsement = this.#endorsementCache.get(contents);
|
||||||
if (endorsement == null) {
|
if (endorsement == null) {
|
||||||
endorsement = new GroupSendEndorsement(Buffer.from(contents));
|
endorsement = new GroupSendEndorsement(Buffer.from(contents));
|
||||||
|
@ -183,73 +184,7 @@ export class GroupSendEndorsementState {
|
||||||
return endorsement;
|
return endorsement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 1: Faster when we're sending to most of the group members
|
#toToken(endorsement: GroupSendEndorsement): GroupSendToken {
|
||||||
// `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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupSecretParams = new GroupSecretParams(
|
const groupSecretParams = new GroupSecretParams(
|
||||||
Buffer.from(this.#groupSecretParamsBase64, 'base64')
|
Buffer.from(this.#groupSecretParamsBase64, 'base64')
|
||||||
);
|
);
|
||||||
|
@ -264,6 +199,105 @@ export class GroupSendEndorsementState {
|
||||||
const fullToken = endorsement.toFullToken(groupSecretParams, expiration);
|
const fullToken = endorsement.toFullToken(groupSecretParams, expiration);
|
||||||
return toGroupSendToken(fullToken.serialize());
|
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 {
|
export function onFailedToSendWithEndorsements(error: Error): void {
|
||||||
|
@ -278,3 +312,58 @@ export function onFailedToSendWithEndorsements(error: Error): void {
|
||||||
}
|
}
|
||||||
devDebugger();
|
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
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { chunk } from 'lodash';
|
import { chunk, map } from 'lodash';
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import type { Receipt } from '../types/Receipt';
|
import type { Receipt } from '../types/Receipt';
|
||||||
import { ReceiptType } from '../types/Receipt';
|
import { ReceiptType } from '../types/Receipt';
|
||||||
|
@ -9,8 +9,9 @@ import { getSendOptions } from './getSendOptions';
|
||||||
import { handleMessageSend } from './handleMessageSend';
|
import { handleMessageSend } from './handleMessageSend';
|
||||||
import { isConversationAccepted } from './isConversationAccepted';
|
import { isConversationAccepted } from './isConversationAccepted';
|
||||||
import { isConversationUnregistered } from './isConversationUnregistered';
|
import { isConversationUnregistered } from './isConversationUnregistered';
|
||||||
import { map } from './iterables';
|
|
||||||
import { missingCaseError } from './missingCaseError';
|
import { missingCaseError } from './missingCaseError';
|
||||||
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
import { mapEmplace } from './mapEmplace';
|
||||||
|
|
||||||
const CHUNK_SIZE = 100;
|
const CHUNK_SIZE = 100;
|
||||||
|
|
||||||
|
@ -57,47 +58,53 @@ export async function sendReceipts({
|
||||||
|
|
||||||
log.info(`Starting receipt send of type ${type}`);
|
log.info(`Starting receipt send of type ${type}`);
|
||||||
|
|
||||||
const receiptsBySenderId: Map<string, Array<Receipt>> = receipts.reduce(
|
type ConversationSenderReceiptGroup = {
|
||||||
(result, receipt) => {
|
conversationId: string;
|
||||||
const { senderE164, senderAci } = receipt;
|
sender: ConversationModel;
|
||||||
if (!senderE164 && !senderAci) {
|
receipts: Array<Receipt>;
|
||||||
log.error('no sender E164 or Service Id. Skipping this receipt');
|
};
|
||||||
return result;
|
const groupsByConversation = new Map<
|
||||||
}
|
string,
|
||||||
|
Map<string, ConversationSenderReceiptGroup>
|
||||||
|
>();
|
||||||
|
|
||||||
const sender = window.ConversationController.lookupOrCreate({
|
const allGroups = new Set<ConversationSenderReceiptGroup>();
|
||||||
e164: senderE164,
|
|
||||||
serviceId: senderAci,
|
|
||||||
reason: 'sendReceipts',
|
|
||||||
});
|
|
||||||
if (!sender) {
|
|
||||||
throw new Error(
|
|
||||||
'no conversation found with that E164/Service Id. Cannot send this receipt'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingGroup = result.get(sender.id);
|
for (const receipt of receipts) {
|
||||||
if (existingGroup) {
|
const { senderE164, senderAci, conversationId } = receipt;
|
||||||
existingGroup.push(receipt);
|
if (!senderE164 && !senderAci) {
|
||||||
} else {
|
log.error('no sender E164 or Service Id. Skipping this receipt');
|
||||||
result.set(sender.id, [receipt]);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
const sender = window.ConversationController.lookupOrCreate({
|
||||||
},
|
e164: senderE164,
|
||||||
new Map()
|
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 window.ConversationController.load();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
map(receiptsBySenderId, async ([senderId, receiptsForSender]) => {
|
Array.from(allGroups.values(), async group => {
|
||||||
const sender = window.ConversationController.get(senderId);
|
const { conversationId, sender, receipts: receiptsForSender } = group;
|
||||||
if (!sender) {
|
|
||||||
throw new Error(
|
|
||||||
'despite having a conversation ID, no conversation was found'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConversationAccepted(sender.attributes)) {
|
if (!isConversationAccepted(sender.attributes)) {
|
||||||
log.info(
|
log.info(
|
||||||
|
@ -120,7 +127,12 @@ export async function sendReceipts({
|
||||||
|
|
||||||
log.info(`Sending receipt of type ${type} to ${sender.idForLogging()}`);
|
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);
|
const batches = chunk(receiptsForSender, CHUNK_SIZE);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|
|
@ -23,7 +23,7 @@ import {
|
||||||
import { Address } from '../types/Address';
|
import { Address } from '../types/Address';
|
||||||
import { QualifiedAddress } from '../types/QualifiedAddress';
|
import { QualifiedAddress } from '../types/QualifiedAddress';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { DataWriter, DataReader } from '../sql/Client';
|
import { DataWriter } from '../sql/Client';
|
||||||
import { getValue } from '../RemoteConfig';
|
import { getValue } from '../RemoteConfig';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import { ServiceIdKind } from '../types/ServiceId';
|
import { ServiceIdKind } from '../types/ServiceId';
|
||||||
|
@ -66,11 +66,11 @@ import { strictAssert } from './assert';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { GLOBAL_ZONE } from '../SignalProtocolStore';
|
import { GLOBAL_ZONE } from '../SignalProtocolStore';
|
||||||
import { waitForAll } from './waitForAll';
|
import { waitForAll } from './waitForAll';
|
||||||
|
import type { GroupSendEndorsementState } from './groupSendEndorsements';
|
||||||
import {
|
import {
|
||||||
GroupSendEndorsementState,
|
maybeCreateGroupSendEndorsementState,
|
||||||
onFailedToSendWithEndorsements,
|
onFailedToSendWithEndorsements,
|
||||||
} from './groupSendEndorsements';
|
} from './groupSendEndorsements';
|
||||||
import { maybeUpdateGroup } from '../groups';
|
|
||||||
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
||||||
import { isAciString } from './isAciString';
|
import { isAciString } from './isAciString';
|
||||||
import { safeParseStrict, safeParseUnknown } from './schemas';
|
import { safeParseStrict, safeParseUnknown } from './schemas';
|
||||||
|
@ -213,11 +213,11 @@ export async function sendContentMessageToGroup(
|
||||||
|
|
||||||
if (sendTarget.isValid()) {
|
if (sendTarget.isValid()) {
|
||||||
try {
|
try {
|
||||||
return await sendToGroupViaSenderKey(
|
return await sendToGroupViaSenderKey(options, {
|
||||||
options,
|
count: 0,
|
||||||
0,
|
didRefreshGroupState: false,
|
||||||
'init (sendContentMessageToGroup)'
|
reason: 'init (sendContentMessageToGroup)',
|
||||||
);
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -259,11 +259,27 @@ export async function sendContentMessageToGroup(
|
||||||
|
|
||||||
// The Primary Sender Key workflow
|
// The Primary Sender Key workflow
|
||||||
|
|
||||||
|
type SendRecursion = {
|
||||||
|
count: number;
|
||||||
|
didRefreshGroupState: boolean;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function sendToGroupViaSenderKey(
|
export async function sendToGroupViaSenderKey(
|
||||||
options: SendToGroupOptions,
|
options: SendToGroupOptions,
|
||||||
recursionCount: number,
|
recursion: SendRecursion
|
||||||
recursionReason: string
|
|
||||||
): Promise<CallbackResultType> {
|
): Promise<CallbackResultType> {
|
||||||
|
function startOver(
|
||||||
|
reason: string,
|
||||||
|
didRefreshGroupState = recursion.didRefreshGroupState
|
||||||
|
) {
|
||||||
|
return sendToGroupViaSenderKey(options, {
|
||||||
|
count: recursion.count + 1,
|
||||||
|
didRefreshGroupState,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contentHint,
|
contentHint,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
|
@ -282,12 +298,12 @@ export async function sendToGroupViaSenderKey(
|
||||||
|
|
||||||
const logId = sendTarget.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
log.info(
|
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(
|
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
|
// Restart here because we updated senderKeyInfo
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('Added missing sender key info');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'Added missing sender key info'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPIRE_DURATION = getSenderKeyExpireDuration();
|
const EXPIRE_DURATION = getSenderKeyExpireDuration();
|
||||||
|
@ -344,11 +356,7 @@ export async function sendToGroupViaSenderKey(
|
||||||
await resetSenderKey(sendTarget);
|
await resetSenderKey(sendTarget);
|
||||||
|
|
||||||
// Restart here because we updated senderKeyInfo
|
// Restart here because we updated senderKeyInfo
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('sender key info expired');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'sender key info expired'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch all devices we believe we'll be sending to
|
// 2. Fetch all devices we believe we'll be sending to
|
||||||
|
@ -356,49 +364,20 @@ export async function sendToGroupViaSenderKey(
|
||||||
const { devices: currentDevices, emptyServiceIds } =
|
const { devices: currentDevices, emptyServiceIds } =
|
||||||
await window.textsecure.storage.protocol.getOpenDevices(ourAci, recipients);
|
await window.textsecure.storage.protocol.getOpenDevices(ourAci, recipients);
|
||||||
|
|
||||||
const conversation =
|
|
||||||
groupId != null
|
|
||||||
? (window.ConversationController.get(groupId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let groupSendEndorsementState: GroupSendEndorsementState | null = null;
|
let groupSendEndorsementState: GroupSendEndorsementState | null = null;
|
||||||
if (groupId != null) {
|
if (groupId != null && !story) {
|
||||||
strictAssert(conversation, 'Must have conversation for endorsements');
|
const { state, didRefreshGroupState } =
|
||||||
|
await maybeCreateGroupSendEndorsementState(
|
||||||
const data = await DataReader.getGroupSendEndorsementsData(groupId);
|
groupId,
|
||||||
if (data == null) {
|
recursion.didRefreshGroupState
|
||||||
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`
|
|
||||||
);
|
);
|
||||||
const groupSecretParamsBase64 = conversation.get('secretParams');
|
if (state != null) {
|
||||||
strictAssert(groupSecretParamsBase64, 'Must have secret params');
|
groupSendEndorsementState = state;
|
||||||
groupSendEndorsementState = new GroupSendEndorsementState(
|
} else if (didRefreshGroupState) {
|
||||||
data,
|
return startOver(
|
||||||
groupSecretParamsBase64
|
'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);
|
await fetchKeysForServiceIds(emptyServiceIds, groupSendEndorsementState);
|
||||||
|
|
||||||
// Restart here to capture devices for accounts we just started sessions with
|
// Restart here to capture devices for accounts we just started sessions with
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('fetched prekey bundles');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'fetched prekey bundles'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { memberDevices, distributionId, createdAtDate } = senderKeyInfo;
|
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
|
// Restart here to start over; empty memberDevices means we'll send distribution
|
||||||
// message to everyone.
|
// message to everyone.
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('removed members in send target');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'removed members in send target'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. If there are new members or new devices in the group, we need to ensure that they
|
// 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
|
// Restart here because we might have discovered new or dropped devices as part of
|
||||||
// distributing our sender key.
|
// distributing our sender key.
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('sent skdm to new members');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'sent skdm to new members'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Update memberDevices with removals which didn't require a reset.
|
// 9. Update memberDevices with removals which didn't require a reset.
|
||||||
|
@ -572,17 +539,12 @@ export async function sendToGroupViaSenderKey(
|
||||||
senderKeyRecipientsWithDevices[serviceId].push(id);
|
senderKeyRecipientsWithDevices[serviceId].push(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
let groupSendToken: GroupSendToken | undefined;
|
let groupSendToken: GroupSendToken | null = null;
|
||||||
let accessKeys: Buffer | undefined;
|
let accessKeys: Buffer | null = null;
|
||||||
if (groupSendEndorsementState != null) {
|
if (groupSendEndorsementState != null) {
|
||||||
strictAssert(conversation, 'Must have conversation for endorsements');
|
groupSendToken = groupSendEndorsementState.buildToken(
|
||||||
try {
|
new Set(senderKeyRecipients)
|
||||||
groupSendToken = groupSendEndorsementState.buildToken(
|
);
|
||||||
new Set(senderKeyRecipients)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
onFailedToSendWithEndorsements(error);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
|
accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
|
||||||
}
|
}
|
||||||
|
@ -656,22 +618,14 @@ export async function sendToGroupViaSenderKey(
|
||||||
await handle409Response(sendTarget, groupSendEndorsementState, error);
|
await handle409Response(sendTarget, groupSendEndorsementState, error);
|
||||||
|
|
||||||
// Restart here to capture the right set of devices for our next send.
|
// Restart here to capture the right set of devices for our next send.
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('error: expired or missing devices');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'error: expired or missing devices'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (error.code === ERROR_STALE_DEVICES) {
|
if (error.code === ERROR_STALE_DEVICES) {
|
||||||
await handle410Response(sendTarget, groupSendEndorsementState, error);
|
await handle410Response(sendTarget, groupSendEndorsementState, error);
|
||||||
|
|
||||||
// Restart here to use the right registrationIds for devices we already knew about,
|
// Restart here to use the right registrationIds for devices we already knew about,
|
||||||
// as well as send our sender key to these re-registered or re-linked devices.
|
// as well as send our sender key to these re-registered or re-linked devices.
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('error: stale devices');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'error: stale devices'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error instanceof LibSignalErrorBase &&
|
error instanceof LibSignalErrorBase &&
|
||||||
|
@ -689,11 +643,7 @@ export async function sendToGroupViaSenderKey(
|
||||||
await DataWriter.updateConversation(brokenAccount.attributes);
|
await DataWriter.updateConversation(brokenAccount.attributes);
|
||||||
|
|
||||||
// Now that we've eliminate this problematic account, we can try the send again.
|
// Now that we've eliminate this problematic account, we can try the send again.
|
||||||
return sendToGroupViaSenderKey(
|
return startOver('error: invalid registration id');
|
||||||
options,
|
|
||||||
recursionCount + 1,
|
|
||||||
'error: invalid registration id'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue