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