2022-07-14 00:46:46 +00:00
|
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
import type {
|
|
|
|
ClientZkProfileOperations,
|
|
|
|
ProfileKeyCredentialRequestContext,
|
|
|
|
} from '@signalapp/libsignal-client/zkgroup';
|
2022-07-14 00:46:46 +00:00
|
|
|
import PQueue from 'p-queue';
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
import type { ReadonlyDeep } from 'type-fest';
|
2022-07-14 00:46:46 +00:00
|
|
|
import type { ConversationModel } from '../models/conversations';
|
2024-10-10 17:57:22 +00:00
|
|
|
import type { CapabilitiesType, ProfileType } from '../textsecure/WebAPI';
|
2024-08-23 22:14:56 +00:00
|
|
|
import MessageSender from '../textsecure/SendMessage';
|
2023-08-10 16:43:33 +00:00
|
|
|
import type { ServiceIdString } from '../types/ServiceId';
|
2024-07-22 18:16:33 +00:00
|
|
|
import { DataWriter } from '../sql/Client';
|
2022-07-14 00:46:46 +00:00
|
|
|
import * as log from '../logging/log';
|
|
|
|
import * as Errors from '../types/errors';
|
|
|
|
import * as Bytes from '../Bytes';
|
|
|
|
import { explodePromise } from '../util/explodePromise';
|
|
|
|
import { isRecord } from '../util/isRecord';
|
|
|
|
import { sleep } from '../util/sleep';
|
|
|
|
import { MINUTE, SECOND } from '../util/durations';
|
|
|
|
import {
|
|
|
|
generateProfileKeyCredentialRequest,
|
|
|
|
getClientZkProfileOperations,
|
|
|
|
handleProfileKeyCredential,
|
|
|
|
} from '../util/zkgroup';
|
|
|
|
import { isMe } from '../util/whatTypeOfConversation';
|
|
|
|
import { getUserLanguages } from '../util/userLanguages';
|
|
|
|
import { parseBadgesFromServer } from '../badges/parseBadgesFromServer';
|
|
|
|
import { strictAssert } from '../util/assert';
|
2022-12-21 18:41:48 +00:00
|
|
|
import { drop } from '../util/drop';
|
2022-07-14 00:46:46 +00:00
|
|
|
import { findRetryAfterTimeFromError } from '../jobs/helpers/findRetryAfterTimeFromError';
|
2024-08-23 22:14:56 +00:00
|
|
|
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
2022-07-14 00:46:46 +00:00
|
|
|
import { SEALED_SENDER } from '../types/SealedSender';
|
|
|
|
import { HTTPError } from '../textsecure/Errors';
|
|
|
|
import { Address } from '../types/Address';
|
|
|
|
import { QualifiedAddress } from '../types/QualifiedAddress';
|
|
|
|
import { trimForDisplay, verifyAccessKey, decryptProfile } from '../Crypto';
|
2024-10-10 17:57:22 +00:00
|
|
|
import type { ConversationLastProfileType } from '../model-types';
|
|
|
|
import type { GroupSendToken } from '../types/GroupSendEndorsements';
|
|
|
|
import {
|
|
|
|
maybeCreateGroupSendEndorsementState,
|
|
|
|
onFailedToSendWithEndorsements,
|
|
|
|
} from '../util/groupSendEndorsements';
|
2022-07-14 00:46:46 +00:00
|
|
|
|
|
|
|
type JobType = {
|
|
|
|
resolve: () => void;
|
|
|
|
reject: (error: Error) => void;
|
|
|
|
promise: Promise<void>;
|
|
|
|
startTime: number;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Goals for this service:
|
2023-01-05 22:29:02 +00:00
|
|
|
// 1. Ensure that when we get a 413/429 from the server, we stop firing off profile
|
2022-07-14 00:46:46 +00:00
|
|
|
// fetches for a while.
|
|
|
|
// 2. Ensure that all existing profile fetches don't hang in this case; to solve this we
|
2023-01-05 22:29:02 +00:00
|
|
|
// cancel all outstanding requests when we hit a 413/429, and throw instead of
|
|
|
|
// queueing something new if we're waiting due to a retry-after. Note: It's no worse
|
|
|
|
// than what we were doing before, failing all requests and pushing the retry-after
|
|
|
|
// time out further.
|
2022-07-14 00:46:46 +00:00
|
|
|
// 3. Require no changes to callers.
|
|
|
|
|
|
|
|
// Potential future goals for this problem area:
|
|
|
|
// - Update all getProfiles() callers; make them resilient to longer delays
|
|
|
|
// - Keep track of last profile fetch per conversation, reduce unnecessary re-fetches
|
|
|
|
// - Enforce a maximum profile fetch frequency
|
|
|
|
// - Don't even attempt jobs when offline
|
|
|
|
|
2024-08-23 22:14:56 +00:00
|
|
|
const OBSERVED_CAPABILITY_KEYS = Object.keys({
|
|
|
|
deleteSync: true,
|
|
|
|
versionedExpirationTimer: true,
|
2024-10-31 17:01:03 +00:00
|
|
|
ssre2: true,
|
2024-08-23 22:14:56 +00:00
|
|
|
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
|
|
|
|
|
2022-07-14 00:46:46 +00:00
|
|
|
export class ProfileService {
|
|
|
|
private jobQueue: PQueue;
|
|
|
|
|
|
|
|
private jobsByConversationId: Map<string, JobType> = new Map();
|
|
|
|
|
|
|
|
private isPaused = false;
|
|
|
|
|
|
|
|
constructor(private fetchProfile = doGetProfile) {
|
|
|
|
this.jobQueue = new PQueue({ concurrency: 3, timeout: MINUTE * 2 });
|
|
|
|
this.jobsByConversationId = new Map();
|
|
|
|
|
|
|
|
log.info('Profile Service initialized');
|
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
public async get(
|
|
|
|
conversationId: string,
|
|
|
|
groupId: string | null
|
|
|
|
): Promise<void> {
|
2022-07-14 00:46:46 +00:00
|
|
|
const preCheckConversation =
|
|
|
|
window.ConversationController.get(conversationId);
|
|
|
|
if (!preCheckConversation) {
|
|
|
|
throw new Error(
|
|
|
|
`ProfileServices.get: Pre-check conversation ${conversationId} not found`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-08-16 20:54:39 +00:00
|
|
|
if (window.ConversationController.isSignalConversationId(conversationId)) {
|
2022-11-09 02:38:19 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-07-14 00:46:46 +00:00
|
|
|
if (this.isPaused) {
|
|
|
|
throw new Error(
|
|
|
|
`ProfileService.get: Cannot add job to paused queue for conversation ${preCheckConversation.idForLogging()}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const existing = this.jobsByConversationId.get(conversationId);
|
|
|
|
if (existing) {
|
|
|
|
return existing.promise;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { resolve, reject, promise } = explodePromise<void>();
|
|
|
|
const jobData = {
|
|
|
|
promise,
|
|
|
|
resolve,
|
|
|
|
reject,
|
|
|
|
startTime: Date.now(),
|
|
|
|
};
|
|
|
|
|
|
|
|
const job = async () => {
|
|
|
|
const conversation = window.ConversationController.get(conversationId);
|
|
|
|
if (!conversation) {
|
|
|
|
throw new Error(
|
|
|
|
`ProfileServices.get: Conversation ${conversationId} not found`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2024-10-10 17:57:22 +00:00
|
|
|
await this.fetchProfile(conversation, groupId);
|
2022-07-14 00:46:46 +00:00
|
|
|
resolve();
|
|
|
|
} catch (error) {
|
|
|
|
reject(error);
|
|
|
|
|
|
|
|
if (this.isPaused) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-02-16 20:40:38 +00:00
|
|
|
if (isRecord(error) && 'code' in error) {
|
|
|
|
if (error.code === -1) {
|
|
|
|
this.clearAll('Failed to connect to the server');
|
|
|
|
} else if (error.code === 413 || error.code === 429) {
|
|
|
|
this.clearAll(`got ${error.code} from server`);
|
|
|
|
const time = findRetryAfterTimeFromError(error);
|
|
|
|
void this.pause(time);
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
this.jobsByConversationId.delete(conversationId);
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
const delta = now - jobData.startTime;
|
|
|
|
if (delta > 30 * SECOND) {
|
|
|
|
log.warn(
|
|
|
|
`ProfileServices.get: Job for ${conversation.idForLogging()} finished ${delta}ms after queue`
|
|
|
|
);
|
|
|
|
}
|
2023-09-25 13:43:54 +00:00
|
|
|
const remainingItems = this.jobQueue.size;
|
|
|
|
if (remainingItems && remainingItems % 10 === 0) {
|
|
|
|
log.info(
|
|
|
|
`ProfileServices.get: ${remainingItems} jobs remaining in the queue`
|
|
|
|
);
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
this.jobsByConversationId.set(conversationId, jobData);
|
2022-12-21 18:41:48 +00:00
|
|
|
drop(this.jobQueue.add(job));
|
2022-07-14 00:46:46 +00:00
|
|
|
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
|
|
|
public clearAll(reason: string): void {
|
|
|
|
if (this.isPaused) {
|
|
|
|
log.warn(
|
|
|
|
`ProfileService.clearAll: Already paused; not clearing; reason: '${reason}'`
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
log.info(`ProfileService.clearAll: Clearing; reason: '${reason}'`);
|
|
|
|
|
|
|
|
try {
|
|
|
|
this.isPaused = true;
|
|
|
|
this.jobQueue.pause();
|
|
|
|
|
|
|
|
this.jobsByConversationId.forEach(job => {
|
|
|
|
job.reject(
|
|
|
|
new Error(
|
|
|
|
`ProfileService.clearAll: job cancelled because '${reason}'`
|
|
|
|
)
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.jobsByConversationId.clear();
|
|
|
|
this.jobQueue.clear();
|
|
|
|
|
|
|
|
this.jobQueue.start();
|
|
|
|
} finally {
|
|
|
|
this.isPaused = false;
|
|
|
|
log.info('ProfileService.clearAll: Done clearing');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public async pause(timeInMS: number): Promise<void> {
|
|
|
|
if (this.isPaused) {
|
|
|
|
log.warn('ProfileService.pause: Already paused, not pausing again.');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
log.info(`ProfileService.pause: Pausing queue for ${timeInMS}ms`);
|
|
|
|
|
|
|
|
this.isPaused = true;
|
|
|
|
this.jobQueue.pause();
|
|
|
|
|
|
|
|
try {
|
|
|
|
await sleep(timeInMS);
|
|
|
|
} finally {
|
|
|
|
log.info('ProfileService.pause: Restarting queue');
|
|
|
|
this.jobQueue.start();
|
|
|
|
this.isPaused = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export const profileService = new ProfileService();
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
|
|
namespace ProfileFetchOptions {
|
|
|
|
type Base = ReadonlyDeep<{
|
|
|
|
request: {
|
|
|
|
userLanguages: ReadonlyArray<string>;
|
|
|
|
};
|
|
|
|
}>;
|
|
|
|
type WithVersioned = ReadonlyDeep<{
|
|
|
|
profileKey: string;
|
|
|
|
profileCredentialRequestContext: ProfileKeyCredentialRequestContext | null;
|
|
|
|
request: {
|
|
|
|
profileKeyVersion: string;
|
|
|
|
profileKeyCredentialRequest: string | null;
|
|
|
|
};
|
|
|
|
}>;
|
|
|
|
type WithUnversioned = ReadonlyDeep<{
|
|
|
|
profileKey: null;
|
|
|
|
profileCredentialRequestContext: null;
|
|
|
|
request: {
|
|
|
|
profileKeyVersion: null;
|
|
|
|
profileKeyCredentialRequest: null;
|
|
|
|
};
|
|
|
|
}>;
|
|
|
|
type WithUnauthAccessKey = ReadonlyDeep<{
|
|
|
|
request: { accessKey: string; groupSendToken: null };
|
|
|
|
}>;
|
|
|
|
type WithUnauthGroupSendToken = ReadonlyDeep<{
|
|
|
|
request: {
|
|
|
|
accessKey: null;
|
|
|
|
groupSendToken: GroupSendToken;
|
|
|
|
};
|
|
|
|
}>;
|
|
|
|
type WithAuth = ReadonlyDeep<{
|
|
|
|
request: {
|
|
|
|
accessKey: null;
|
|
|
|
groupSendToken: null;
|
|
|
|
};
|
|
|
|
}>;
|
|
|
|
|
|
|
|
export type Unauth =
|
|
|
|
// versioned (unauth)
|
|
|
|
| (Base & WithVersioned & WithUnauthAccessKey)
|
|
|
|
// unversioned (unauth)
|
|
|
|
| (Base & WithUnversioned & WithUnauthAccessKey)
|
|
|
|
| (Base & WithUnversioned & WithUnauthGroupSendToken);
|
|
|
|
|
|
|
|
export type Auth =
|
|
|
|
// unversioned (auth) -- Using lastProfile
|
|
|
|
| (Base & WithVersioned & WithAuth)
|
|
|
|
// unversioned (auth)
|
|
|
|
| (Base & WithUnversioned & WithAuth);
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
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()})`;
|
2022-07-14 00:46:46 +00:00
|
|
|
|
|
|
|
const userLanguages = getUserLanguages(
|
2023-08-07 20:28:09 +00:00
|
|
|
window.SignalContext.getPreferredSystemLocales(),
|
|
|
|
window.SignalContext.getResolvedMessagesLocale()
|
2022-07-14 00:46:46 +00:00
|
|
|
);
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
const profileKey = conversation.get('profileKey');
|
|
|
|
const profileKeyVersion = conversation.deriveProfileKeyVersion();
|
|
|
|
const accessKey = conversation.get('accessKey');
|
|
|
|
const serviceId = conversation.getCheckedServiceId('getProfile');
|
2022-07-14 00:46:46 +00:00
|
|
|
|
|
|
|
if (profileKey) {
|
|
|
|
strictAssert(
|
2022-09-14 16:40:50 +00:00
|
|
|
profileKeyVersion != null && accessKey != null,
|
2024-10-10 17:57:22 +00:00
|
|
|
`${logId}: profileKeyVersion and accessKey are derived from profileKey`
|
2022-07-14 00:46:46 +00:00
|
|
|
);
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
if (!conversation.hasProfileKeyCredentialExpired()) {
|
|
|
|
log.info(`${logId}: using unexpired profile key credential`);
|
|
|
|
return {
|
|
|
|
profileKey,
|
|
|
|
profileCredentialRequestContext: null,
|
|
|
|
request: {
|
|
|
|
userLanguages,
|
|
|
|
accessKey,
|
|
|
|
groupSendToken: null,
|
|
|
|
profileKeyVersion,
|
|
|
|
profileKeyCredentialRequest: null,
|
|
|
|
},
|
2022-07-14 00:46:46 +00:00
|
|
|
};
|
2024-10-10 17:57:22 +00:00
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
log.info(`${logId}: generating profile key credential request`);
|
|
|
|
const result = generateProfileKeyCredentialRequest(
|
|
|
|
clientZkProfileCipher,
|
|
|
|
serviceId,
|
|
|
|
profileKey
|
|
|
|
);
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
return {
|
|
|
|
profileKey,
|
|
|
|
profileCredentialRequestContext: result.context,
|
|
|
|
request: {
|
2022-07-14 00:46:46 +00:00
|
|
|
userLanguages,
|
2024-10-10 17:57:22 +00:00
|
|
|
accessKey,
|
|
|
|
groupSendToken: null,
|
2022-07-14 00:46:46 +00:00
|
|
|
profileKeyVersion,
|
2024-10-10 17:57:22 +00:00
|
|
|
profileKeyCredentialRequest: result.requestHex,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
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: {
|
2022-07-14 00:46:46 +00:00
|
|
|
userLanguages,
|
2024-10-10 17:57:22 +00:00
|
|
|
accessKey: null,
|
|
|
|
groupSendToken: null,
|
2022-07-14 00:46:46 +00:00
|
|
|
profileKeyVersion: lastProfile.profileKeyVersion,
|
2024-10-10 17:57:22 +00:00
|
|
|
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,
|
|
|
|
},
|
2022-07-14 00:46:46 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Fallback to auth
|
|
|
|
return {
|
|
|
|
profileKey: null,
|
|
|
|
profileCredentialRequestContext: null,
|
|
|
|
request: {
|
|
|
|
userLanguages,
|
|
|
|
accessKey: null,
|
|
|
|
groupSendToken: null,
|
|
|
|
profileKeyVersion: null,
|
|
|
|
profileKeyCredentialRequest: null,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function decryptField(field: string, decryptionKey: Uint8Array): Uint8Array {
|
|
|
|
return decryptProfile(Bytes.fromBase64(field), decryptionKey);
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatTextField(decrypted: Uint8Array): string {
|
|
|
|
return Bytes.toString(trimForDisplay(decrypted));
|
|
|
|
}
|
|
|
|
|
|
|
|
function isFieldDefined(field: string | null | undefined): field is string {
|
|
|
|
return field != null && field.length > 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
function getFetchOptionsLabel(
|
|
|
|
options: ProfileFetchOptions.Auth | ProfileFetchOptions.Unauth
|
|
|
|
) {
|
|
|
|
let versioned: string;
|
|
|
|
if (options.request.profileKeyVersion != null) {
|
|
|
|
versioned = 'versioned';
|
|
|
|
} else {
|
|
|
|
versioned = 'unversioned';
|
|
|
|
}
|
|
|
|
let auth: string;
|
|
|
|
if (options.request.accessKey != null) {
|
|
|
|
auth = 'unauth: accessKey';
|
|
|
|
} else if (options.request.groupSendToken != null) {
|
|
|
|
auth = 'unauth: groupSendToken';
|
|
|
|
} else {
|
|
|
|
auth = 'auth';
|
|
|
|
}
|
|
|
|
return `${versioned}, ${auth}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function doGetProfile(
|
|
|
|
c: ConversationModel,
|
|
|
|
groupId: string | null
|
|
|
|
): Promise<void> {
|
2024-10-21 21:33:24 +00:00
|
|
|
const logId = groupId
|
|
|
|
? `getProfile(${c.idForLogging()} in groupv2(${groupId}))`
|
|
|
|
: `getProfile(${c.idForLogging()})`;
|
2024-10-10 17:57:22 +00:00
|
|
|
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()
|
2022-07-14 00:46:46 +00:00
|
|
|
);
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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;
|
2022-07-14 00:46:46 +00:00
|
|
|
try {
|
2024-10-10 17:57:22 +00:00
|
|
|
if (request.accessKey != null || request.groupSendToken != null) {
|
|
|
|
profile = await messaging.server.getProfileUnauth(serviceId, request);
|
|
|
|
} else {
|
|
|
|
profile = await messaging.server.getProfile(serviceId, request);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
log.error(`${logId}: Failed to fetch profile`, Errors.toLogFormat(error));
|
|
|
|
|
|
|
|
if (error instanceof HTTPError) {
|
|
|
|
// Unauthorized/Forbidden
|
|
|
|
if (error.code === 401 || error.code === 403) {
|
|
|
|
if (request.groupSendToken != null) {
|
|
|
|
onFailedToSendWithEndorsements(error);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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',
|
|
|
|
});
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Retry fetch using last known profileKeyVersion or fetch
|
|
|
|
// unversioned profile.
|
|
|
|
return doGetProfile(c, groupId);
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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);
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Step #: Record if the accessKey we have in the conversation is valid
|
|
|
|
const sealedSender = c.get('sealedSender');
|
|
|
|
if (
|
|
|
|
sealedSender === SEALED_SENDER.ENABLED ||
|
|
|
|
sealedSender === SEALED_SENDER.UNRESTRICTED
|
|
|
|
) {
|
|
|
|
if (!isMe(c.attributes)) {
|
|
|
|
log.warn(
|
|
|
|
`${logId}: Got ${error.code} when using accessKey, removing profileKey`
|
|
|
|
);
|
|
|
|
await c.setProfileKey(undefined, {
|
|
|
|
reason: 'doGetProfile/accessKey/401+403',
|
|
|
|
});
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
} else if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
|
|
|
log.warn(
|
|
|
|
`${logId}: Got ${error.code} fetching profile, setting sealedSender = DISABLED`
|
|
|
|
);
|
|
|
|
c.set('sealedSender', SEALED_SENDER.DISABLED);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
|
|
|
|
// TODO: Is it safe to ignore these errors?
|
|
|
|
return;
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Not Found
|
|
|
|
if (error.code === 404) {
|
|
|
|
log.info(`${logId}: Profile not found`);
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
c.set('profileLastFetchedAt', Date.now());
|
|
|
|
// Note: Writes to DB:
|
|
|
|
await c.removeLastProfile(lastProfile);
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
if (!isVersioned) {
|
|
|
|
log.info(`${logId}: Marking conversation unregistered`);
|
|
|
|
c.setUnregistered();
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// throw all unhandled errors
|
|
|
|
throw error;
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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)
|
2022-07-14 00:46:46 +00:00
|
|
|
);
|
2024-10-10 17:57:22 +00:00
|
|
|
if (haveCorrectKey) {
|
|
|
|
sealedSender = SEALED_SENDER.ENABLED;
|
|
|
|
} else {
|
|
|
|
log.info(
|
|
|
|
`${logId}: Access key mismatch with profile.unidentifiedAccess`
|
|
|
|
);
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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 });
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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));
|
2024-01-02 19:36:49 +00:00
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
} else {
|
|
|
|
c.unset('about');
|
|
|
|
}
|
2024-01-02 19:36:49 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Step #: Save profile `aboutEmoji` to conversation
|
|
|
|
if (isFieldDefined(profile.aboutEmoji)) {
|
|
|
|
if (updatedDecryptionKey != null) {
|
|
|
|
const decrypted = decryptField(profile.aboutEmoji, updatedDecryptionKey);
|
|
|
|
c.set('aboutEmoji', formatTextField(decrypted));
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
} else {
|
|
|
|
c.unset('aboutEmoji');
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
} else {
|
|
|
|
c.unset('sharingPhoneNumber');
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Step #: Save our own `paymentAddress` to Storage
|
|
|
|
if (isFieldDefined(profile.paymentAddress) && isMe(c.attributes)) {
|
|
|
|
await window.storage.put('paymentAddress', profile.paymentAddress);
|
|
|
|
}
|
2024-08-23 22:14:56 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Step #: Save profile `capabilities` to conversation
|
|
|
|
const pastCapabilities = c.get('capabilities');
|
|
|
|
if (profile.capabilities != null) {
|
|
|
|
c.set({ capabilities: profile.capabilities });
|
|
|
|
} else {
|
|
|
|
c.unset('capabilities');
|
|
|
|
}
|
2024-08-23 22:14:56 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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;
|
2024-08-23 22:14:56 +00:00
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
if (newCapabilities?.[key]) {
|
|
|
|
if (!pastCapabilities?.[key]) {
|
|
|
|
hasChanged = true;
|
|
|
|
newKeys.push(key);
|
|
|
|
}
|
|
|
|
observedCapabilities[key] = true;
|
2024-08-23 22:14:56 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
await window.storage.put('observedCapabilities', observedCapabilities);
|
|
|
|
if (hasChanged) {
|
|
|
|
log.info(
|
|
|
|
'getProfile: detected a capability flip, sending fetch profile',
|
|
|
|
newKeys
|
|
|
|
);
|
|
|
|
await singleProtoJobQueue.add(
|
|
|
|
MessageSender.getFetchLocalProfileSyncMessage()
|
|
|
|
);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
2024-10-10 17:57:22 +00:00
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// Step #: Save profile `badges` to conversation and update redux
|
|
|
|
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
|
|
|
|
if (badges.length) {
|
|
|
|
window.reduxActions.badges.updateOrCreate(badges);
|
|
|
|
c.set({
|
|
|
|
badges: badges.map(badge => ({
|
|
|
|
id: badge.id,
|
|
|
|
...('expiresAt' in badge
|
|
|
|
? {
|
|
|
|
expiresAt: badge.expiresAt,
|
|
|
|
isVisible: badge.isVisible,
|
|
|
|
}
|
|
|
|
: {}),
|
|
|
|
})),
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
c.unset('badges');
|
|
|
|
}
|
2022-07-14 00:46:46 +00:00
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// 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');
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-10 17:57:22 +00:00
|
|
|
// TODO: Should this track other failures?
|
2022-07-14 00:46:46 +00:00
|
|
|
let isSuccessfullyDecrypted = true;
|
2024-10-10 17:57:22 +00:00
|
|
|
|
|
|
|
// Step #: Save profile `name` to conversation
|
|
|
|
if (isFieldDefined(profile.name)) {
|
|
|
|
if (requestDecryptionKey != null) {
|
2022-07-14 00:46:46 +00:00
|
|
|
try {
|
2024-10-10 17:57:22 +00:00
|
|
|
// Note: Writes to DB and saves message
|
|
|
|
await c.setEncryptedProfileName(profile.name, requestDecryptionKey);
|
2022-07-14 00:46:46 +00:00
|
|
|
} catch (error) {
|
|
|
|
log.warn(
|
2024-10-10 17:57:22 +00:00
|
|
|
`${logId}: Failed to decrypt profile name`,
|
2022-07-14 00:46:46 +00:00
|
|
|
Errors.toLogFormat(error)
|
|
|
|
);
|
|
|
|
isSuccessfullyDecrypted = false;
|
2024-10-10 17:57:22 +00:00
|
|
|
c.set({
|
2022-07-14 00:46:46 +00:00
|
|
|
profileName: undefined,
|
|
|
|
profileFamilyName: undefined,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
c.set({
|
|
|
|
profileName: undefined,
|
|
|
|
profileFamilyName: undefined,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2024-10-10 17:57:22 +00:00
|
|
|
if (requestDecryptionKey != null) {
|
|
|
|
// Note: Fetches avatar
|
|
|
|
await c.setAndMaybeFetchProfileAvatar(
|
|
|
|
profile.avatar,
|
|
|
|
requestDecryptionKey
|
|
|
|
);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
if (error instanceof HTTPError) {
|
2024-10-10 17:57:22 +00:00
|
|
|
// Forbidden/Not Found
|
2022-07-14 00:46:46 +00:00
|
|
|
if (error.code === 403 || error.code === 404) {
|
2024-10-10 17:57:22 +00:00
|
|
|
log.warn(`${logId}: Profile avatar is missing (${error.code})`);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
log.warn(
|
2024-10-10 17:57:22 +00:00
|
|
|
`${logId}: Failed to decrypt profile avatar`,
|
2022-07-14 00:46:46 +00:00
|
|
|
Errors.toLogFormat(error)
|
|
|
|
);
|
|
|
|
isSuccessfullyDecrypted = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
c.set('profileLastFetchedAt', Date.now());
|
|
|
|
|
|
|
|
// After we successfully decrypted - update lastProfile property
|
|
|
|
if (
|
|
|
|
isSuccessfullyDecrypted &&
|
2024-10-10 17:57:22 +00:00
|
|
|
options.profileKey &&
|
|
|
|
request.profileKeyVersion
|
2022-07-14 00:46:46 +00:00
|
|
|
) {
|
|
|
|
await c.updateLastProfile(lastProfile, {
|
2024-10-10 17:57:22 +00:00
|
|
|
profileKey: options.profileKey,
|
|
|
|
profileKeyVersion: request.profileKeyVersion,
|
2022-07-14 00:46:46 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-07-22 18:16:33 +00:00
|
|
|
await DataWriter.updateConversation(c.attributes);
|
2022-07-14 00:46:46 +00:00
|
|
|
}
|
|
|
|
|
2024-03-12 01:08:38 +00:00
|
|
|
export type UpdateIdentityKeyOptionsType = Readonly<{
|
|
|
|
noOverwrite?: boolean;
|
|
|
|
}>;
|
|
|
|
|
2022-08-19 18:05:31 +00:00
|
|
|
export async function updateIdentityKey(
|
2024-03-12 01:08:38 +00:00
|
|
|
identityKey: Uint8Array,
|
|
|
|
serviceId: ServiceIdString,
|
|
|
|
{ noOverwrite = false }: UpdateIdentityKeyOptionsType = {}
|
|
|
|
): Promise<boolean> {
|
|
|
|
if (!Bytes.isNotEmpty(identityKey)) {
|
|
|
|
return false;
|
2022-08-19 18:05:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const changed = await window.textsecure.storage.protocol.saveIdentity(
|
2023-08-10 16:43:33 +00:00
|
|
|
new Address(serviceId, 1),
|
2024-03-12 01:08:38 +00:00
|
|
|
identityKey,
|
|
|
|
false,
|
|
|
|
{ noOverwrite }
|
2022-08-19 18:05:31 +00:00
|
|
|
);
|
|
|
|
if (changed) {
|
2023-08-10 16:43:33 +00:00
|
|
|
log.info(`updateIdentityKey(${serviceId}): changed`);
|
2022-08-19 18:05:31 +00:00
|
|
|
// save identity will close all sessions except for .1, so we
|
|
|
|
// must close that one manually.
|
2023-08-10 16:43:33 +00:00
|
|
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
2022-08-19 18:05:31 +00:00
|
|
|
await window.textsecure.storage.protocol.archiveSession(
|
2023-08-10 16:43:33 +00:00
|
|
|
new QualifiedAddress(ourAci, new Address(serviceId, 1))
|
2022-08-19 18:05:31 +00:00
|
|
|
);
|
|
|
|
}
|
2024-03-12 01:08:38 +00:00
|
|
|
|
|
|
|
return changed;
|
2022-08-19 18:05:31 +00:00
|
|
|
}
|