Support endorsements for group 1:1 sends

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
Jamie Kyle 2024-10-10 10:57:22 -07:00 committed by GitHub
parent 76a77a9b7f
commit e617981e59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1296 additions and 796 deletions

1
package-lock.json generated
View file

@ -7355,7 +7355,6 @@
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},

View file

@ -3183,9 +3183,13 @@ export async function startApp(): Promise<void> {
switch (eventType) {
case FETCH_LATEST_ENUM.LOCAL_PROFILE: {
log.info('onFetchLatestSync: fetching latest local profile');
const ourAci = window.textsecure.storage.user.getAci();
const ourE164 = window.textsecure.storage.user.getNumber();
await getProfile(ourAci, ourE164);
const ourAci = window.textsecure.storage.user.getAci() ?? null;
const ourE164 = window.textsecure.storage.user.getNumber() ?? null;
await getProfile({
serviceId: ourAci,
e164: ourE164,
groupId: null,
});
break;
}
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:

View file

@ -100,6 +100,7 @@ import {
decodeGroupSendEndorsementResponse,
isValidGroupSendEndorsementsExpiration,
} from './util/groupSendEndorsements';
import { getProfile } from './util/getProfile';
import { generateMessageId } from './util/generateMessageId';
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
@ -3020,11 +3021,10 @@ export async function waitThenMaybeUpdateGroup(
{ viaFirstStorageSync = false } = {}
): Promise<void> {
const { conversation } = options;
const logId = `waitThenMaybeUpdateGroup(${conversation.idForLogging()})`;
if (conversation.isBlocked()) {
log.info(
`waitThenMaybeUpdateGroup: Group ${conversation.idForLogging()} is blocked, returning early`
);
log.info(`${logId}: Conversation is blocked, returning early`);
return;
}
@ -3039,12 +3039,13 @@ export async function waitThenMaybeUpdateGroup(
) {
const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date.now();
log.info(
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: group update ` +
`was fetched recently, skipping for ${waitTime}ms`
`${logId}: group update was fetched recently, skipping for ${waitTime}ms`
);
return;
}
log.info(`${logId}: group update was not fetched recently, queuing update`);
// Then wait to process all outstanding messages for this conversation
await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
try {
@ -3054,7 +3055,7 @@ export async function waitThenMaybeUpdateGroup(
conversation.lastSuccessfulGroupFetch = Date.now();
} catch (error) {
log.error(
`waitThenMaybeUpdateGroup/${conversation.idForLogging()}: maybeUpdateGroup failure:`,
`${logId}: maybeUpdateGroup failure:`,
Errors.toLogFormat(error)
);
}
@ -3072,7 +3073,7 @@ export async function maybeUpdateGroup(
}: MaybeUpdatePropsType,
{ viaFirstStorageSync = false } = {}
): Promise<void> {
const logId = conversation.idForLogging();
const logId = `maybeUpdateGroup/${conversation.idForLogging()}`;
try {
// Ensure we have the credentials we need before attempting GroupsV2 operations
@ -3091,10 +3092,7 @@ export async function maybeUpdateGroup(
{ viaFirstStorageSync }
);
} catch (error) {
log.error(
`maybeUpdateGroup/${logId}: Failed to update group:`,
Errors.toLogFormat(error)
);
log.error(`${logId}: Failed to update group:`, Errors.toLogFormat(error));
throw error;
}
}
@ -3205,7 +3203,13 @@ async function updateGroup(
);
profileFetches = Promise.all(
contactsWithoutProfileKey.map(contact => contact.getProfiles())
contactsWithoutProfileKey.map(contact => {
return getProfile({
serviceId: contact.getServiceId() ?? null,
e164: contact.get('e164') ?? null,
groupId: newAttributes.groupId ?? null,
});
})
);
}

View file

@ -78,9 +78,10 @@ const callingMessageJobDataSchema = z.object({
conversationId: z.string(),
protoBase64: z.string(),
urgent: z.boolean(),
// These two are group-only
// These are group-only
recipients: z.array(serviceIdSchema).optional(),
isPartialSend: z.boolean().optional(),
groupId: z.string().optional(),
});
export type CallingMessageJobData = z.infer<typeof callingMessageJobDataSchema>;

View file

@ -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)

View file

@ -1171,6 +1171,7 @@ export class ConversationModel extends window.Backbone
options: { force?: boolean } = {}
): Promise<void> {
if (!isGroupV2(this.attributes)) {
log.info('fetchLatestGroupV2Data: Not groupV2');
return;
}
@ -4872,9 +4873,17 @@ export class ConversationModel extends window.Backbone
const conversations =
this.getMembers() as unknown as Array<ConversationModel>;
const groupId = isGroupV2(this.attributes)
? (this.get('groupId') ?? null)
: null;
await Promise.all(
conversations.map(conversation =>
getProfile(conversation.getServiceId(), conversation.get('e164'))
getProfile({
serviceId: conversation.getServiceId() ?? null,
e164: conversation.get('e164') ?? null,
groupId,
})
)
);
}
@ -4916,7 +4925,7 @@ export class ConversationModel extends window.Backbone
}
}
async setProfileAvatar(
async setAndMaybeFetchProfileAvatar(
avatarUrl: undefined | null | string,
decryptionKey: Uint8Array
): Promise<void> {
@ -4964,6 +4973,11 @@ export class ConversationModel extends window.Backbone
reason,
}: { viaStorageServiceSync?: boolean; reason: string }
): Promise<boolean> {
strictAssert(
profileKey == null || profileKey.length > 0,
'setProfileKey: Profile key cannot be an empty string'
);
const oldProfileKey = this.get('profileKey');
// profileKey is a string so we can compare it directly

View file

@ -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()}`
);

View file

@ -2676,6 +2676,7 @@ export class CallingClass {
urgent,
isPartialSend,
recipients,
groupId,
});
log.info('handleSendCallMessageToGroup() completed successfully');

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assertDev } from '../util/assert';
import { assertDev, strictAssert } from '../util/assert';
import * as log from '../logging/log';
import type { StorageInterface } from '../types/Storage.d';
@ -40,11 +40,16 @@ export class OurProfileKeyService {
}
async set(newValue: undefined | Uint8Array): Promise<void> {
log.info('Our profile key service: updating profile key');
assertDev(this.storage, 'OurProfileKeyService was not initialized');
if (newValue) {
if (newValue != null) {
strictAssert(
newValue.byteLength > 0,
'Our profile key service: Profile key cannot be empty'
);
log.info('Our profile key service: updating profile key');
await this.storage.put('profileKey', newValue);
} else {
log.info('Our profile key service: removing profile key');
await this.storage.remove('profileKey');
}
}

View file

@ -1,16 +1,15 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ProfileKeyCredentialRequestContext } from '@signalapp/libsignal-client/zkgroup';
import PQueue from 'p-queue';
import { isNumber } from 'lodash';
import type { ConversationModel } from '../models/conversations';
import type {
GetProfileOptionsType,
GetProfileUnauthOptionsType,
CapabilitiesType,
} from '../textsecure/WebAPI';
ClientZkProfileOperations,
ProfileKeyCredentialRequestContext,
} from '@signalapp/libsignal-client/zkgroup';
import PQueue from 'p-queue';
import type { ReadonlyDeep } from 'type-fest';
import type { ConversationModel } from '../models/conversations';
import type { CapabilitiesType, ProfileType } from '../textsecure/WebAPI';
import MessageSender from '../textsecure/SendMessage';
import type { ServiceIdString } from '../types/ServiceId';
import { DataWriter } from '../sql/Client';
@ -38,6 +37,12 @@ import { HTTPError } from '../textsecure/Errors';
import { Address } from '../types/Address';
import { QualifiedAddress } from '../types/QualifiedAddress';
import { trimForDisplay, verifyAccessKey, decryptProfile } from '../Crypto';
import type { ConversationLastProfileType } from '../model-types';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
import {
maybeCreateGroupSendEndorsementState,
onFailedToSendWithEndorsements,
} from '../util/groupSendEndorsements';
type JobType = {
resolve: () => void;
@ -81,7 +86,10 @@ export class ProfileService {
log.info('Profile Service initialized');
}
public async get(conversationId: string): Promise<void> {
public async get(
conversationId: string,
groupId: string | null
): Promise<void> {
const preCheckConversation =
window.ConversationController.get(conversationId);
if (!preCheckConversation) {
@ -122,7 +130,7 @@ export class ProfileService {
}
try {
await this.fetchProfile(conversation);
await this.fetchProfile(conversation, groupId);
resolve();
} catch (error) {
reject(error);
@ -220,388 +228,561 @@ export class ProfileService {
export const profileService = new ProfileService();
async function doGetProfile(c: ConversationModel): Promise<void> {
const idForLogging = c.idForLogging();
const { messaging } = window.textsecure;
strictAssert(
messaging,
'getProfile: window.textsecure.messaging not available'
);
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ProfileFetchOptions {
type Base = ReadonlyDeep<{
request: {
userLanguages: ReadonlyArray<string>;
};
}>;
type WithVersioned = ReadonlyDeep<{
profileKey: string;
profileCredentialRequestContext: ProfileKeyCredentialRequestContext | null;
request: {
profileKeyVersion: string;
profileKeyCredentialRequest: string | null;
};
}>;
type WithUnversioned = ReadonlyDeep<{
profileKey: null;
profileCredentialRequestContext: null;
request: {
profileKeyVersion: null;
profileKeyCredentialRequest: null;
};
}>;
type WithUnauthAccessKey = ReadonlyDeep<{
request: { accessKey: string; groupSendToken: null };
}>;
type WithUnauthGroupSendToken = ReadonlyDeep<{
request: {
accessKey: null;
groupSendToken: GroupSendToken;
};
}>;
type WithAuth = ReadonlyDeep<{
request: {
accessKey: null;
groupSendToken: null;
};
}>;
const { updatesUrl } = window.SignalContext.config;
strictAssert(
typeof updatesUrl === 'string',
'getProfile: expected updatesUrl to be a defined string'
);
export type Unauth =
// versioned (unauth)
| (Base & WithVersioned & WithUnauthAccessKey)
// unversioned (unauth)
| (Base & WithUnversioned & WithUnauthAccessKey)
| (Base & WithUnversioned & WithUnauthGroupSendToken);
const clientZkProfileCipher = getClientZkProfileOperations(
window.getServerPublicParams()
);
export type Auth =
// unversioned (auth) -- Using lastProfile
| (Base & WithVersioned & WithAuth)
// unversioned (auth)
| (Base & WithUnversioned & WithAuth);
}
export type ProfileFetchUnauthRequestOptions =
ProfileFetchOptions.Unauth['request'];
export type ProfileFetchAuthRequestOptions =
ProfileFetchOptions.Auth['request'];
async function buildProfileFetchOptions({
conversation,
lastProfile,
clientZkProfileCipher,
groupId,
}: {
conversation: ConversationModel;
lastProfile: ConversationLastProfileType | null;
clientZkProfileCipher: ClientZkProfileOperations;
groupId: string | null;
}): Promise<ProfileFetchOptions.Auth | ProfileFetchOptions.Unauth> {
const logId = `buildGetProfileOptions(${conversation.idForLogging()})`;
const userLanguages = getUserLanguages(
window.SignalContext.getPreferredSystemLocales(),
window.SignalContext.getResolvedMessagesLocale()
);
let profile;
const profileKey = conversation.get('profileKey');
const profileKeyVersion = conversation.deriveProfileKeyVersion();
const accessKey = conversation.get('accessKey');
const serviceId = conversation.getCheckedServiceId('getProfile');
c.deriveAccessKeyIfNeeded();
const profileKey = c.get('profileKey');
const profileKeyVersion = c.deriveProfileKeyVersion();
const serviceId = c.getCheckedServiceId('getProfile');
const lastProfile = c.get('lastProfile');
let profileCredentialRequestContext:
| undefined
| ProfileKeyCredentialRequestContext;
let getProfileOptions: GetProfileOptionsType | GetProfileUnauthOptionsType;
let accessKey = c.get('accessKey');
if (profileKey) {
strictAssert(
profileKeyVersion != null && accessKey != null,
'profileKeyVersion and accessKey are derived from profileKey'
`${logId}: profileKeyVersion and accessKey are derived from profileKey`
);
if (!c.hasProfileKeyCredentialExpired()) {
getProfileOptions = {
accessKey,
profileKeyVersion,
userLanguages,
};
} else {
log.info(
'getProfile: generating profile key credential request for ' +
`conversation ${idForLogging}`
);
let profileKeyCredentialRequestHex: undefined | string;
({
requestHex: profileKeyCredentialRequestHex,
context: profileCredentialRequestContext,
} = generateProfileKeyCredentialRequest(
clientZkProfileCipher,
serviceId,
profileKey
));
getProfileOptions = {
accessKey,
userLanguages,
profileKeyVersion,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
if (!conversation.hasProfileKeyCredentialExpired()) {
log.info(`${logId}: using unexpired profile key credential`);
return {
profileKey,
profileCredentialRequestContext: null,
request: {
userLanguages,
accessKey,
groupSendToken: null,
profileKeyVersion,
profileKeyCredentialRequest: null,
},
};
}
} else {
strictAssert(
!accessKey,
'accessKey have to be absent because there is no profileKey'
log.info(`${logId}: generating profile key credential request`);
const result = generateProfileKeyCredentialRequest(
clientZkProfileCipher,
serviceId,
profileKey
);
if (lastProfile?.profileKeyVersion) {
getProfileOptions = {
return {
profileKey,
profileCredentialRequestContext: result.context,
request: {
userLanguages,
accessKey,
groupSendToken: null,
profileKeyVersion,
profileKeyCredentialRequest: result.requestHex,
},
};
}
strictAssert(
accessKey == null,
`${logId}: accessKey have to be absent because there is no profileKey`
);
// If we have a `lastProfile`, try getting the versioned profile with auth.
// Note: We can't try the group send token here because the versioned profile
// can't be decrypted without an up to date profile key.
if (
lastProfile != null &&
lastProfile.profileKey != null &&
lastProfile.profileKeyVersion != null
) {
log.info(`${logId}: using last profile key and version`);
return {
profileKey: lastProfile.profileKey,
profileCredentialRequestContext: null,
request: {
userLanguages,
accessKey: null,
groupSendToken: null,
profileKeyVersion: lastProfile.profileKeyVersion,
profileKeyCredentialRequest: null,
},
};
}
// Fallback to group send tokens for unversioned profiles
if (groupId != null) {
log.info(`${logId}: fetching group endorsements`);
let result = await maybeCreateGroupSendEndorsementState(groupId, false);
if (result.state == null && result.didRefreshGroupState) {
result = await maybeCreateGroupSendEndorsementState(groupId, true);
}
const groupSendEndorsementState = result.state;
const groupSendToken = groupSendEndorsementState?.buildToken(
new Set([serviceId])
);
if (groupSendToken != null) {
log.info(`${logId}: using group send token`);
return {
profileKey: null,
profileCredentialRequestContext: null,
request: {
userLanguages,
accessKey: null,
groupSendToken,
profileKeyVersion: null,
profileKeyCredentialRequest: null,
},
};
} else {
getProfileOptions = { userLanguages };
}
}
const isVersioned = Boolean(getProfileOptions.profileKeyVersion);
log.info(
`getProfile: getting ${isVersioned ? 'versioned' : 'unversioned'} ` +
`profile for conversation ${idForLogging}`
// Fallback to auth
return {
profileKey: null,
profileCredentialRequestContext: null,
request: {
userLanguages,
accessKey: null,
groupSendToken: null,
profileKeyVersion: null,
profileKeyCredentialRequest: null,
},
};
}
function decryptField(field: string, decryptionKey: Uint8Array): Uint8Array {
return decryptProfile(Bytes.fromBase64(field), decryptionKey);
}
function formatTextField(decrypted: Uint8Array): string {
return Bytes.toString(trimForDisplay(decrypted));
}
function isFieldDefined(field: string | null | undefined): field is string {
return field != null && field.length > 0;
}
function getFetchOptionsLabel(
options: ProfileFetchOptions.Auth | ProfileFetchOptions.Unauth
) {
let versioned: string;
if (options.request.profileKeyVersion != null) {
versioned = 'versioned';
} else {
versioned = 'unversioned';
}
let auth: string;
if (options.request.accessKey != null) {
auth = 'unauth: accessKey';
} else if (options.request.groupSendToken != null) {
auth = 'unauth: groupSendToken';
} else {
auth = 'auth';
}
return `${versioned}, ${auth}`;
}
async function doGetProfile(
c: ConversationModel,
groupId: string | null
): Promise<void> {
const logId = `getProfile(${c.idForLogging()})`;
const { messaging } = window.textsecure;
strictAssert(
messaging,
`${logId}: window.textsecure.messaging not available`
);
const { updatesUrl } = window.SignalContext.config;
strictAssert(
typeof updatesUrl === 'string',
`${logId}: expected updatesUrl to be a defined string`
);
const clientZkProfileCipher = getClientZkProfileOperations(
window.getServerPublicParams()
);
// Step #: Make sure we have an access key if we have a profile key.
c.deriveAccessKeyIfNeeded();
const serviceId = c.getCheckedServiceId('getProfile');
// Step #: Grab the profile key and version we last were successful decrypting with
// `lastProfile` is saved at the end of `doGetProfile` after successfully decrypting.
// `lastProfile` is used in case the `profileKey` was cleared because of a 401/403.
// `lastProfile` is cleared when we get a 404 fetching a profile.
const lastProfile = c.get('lastProfile');
// Step #: Build the request options we will use for fetching and decrypting the profile
const options = await buildProfileFetchOptions({
conversation: c,
lastProfile: lastProfile ?? null,
clientZkProfileCipher,
groupId,
});
const { request } = options;
const isVersioned = request.profileKeyVersion != null;
log.info(`${logId}: Fetching profile (${getFetchOptionsLabel(options)})`);
// Step #: Fetch profile
let profile: ProfileType;
try {
if (getProfileOptions.accessKey) {
try {
profile = await messaging.getProfile(serviceId, getProfileOptions);
} catch (error) {
if (!(error instanceof HTTPError)) {
throw error;
}
if (error.code === 401 || error.code === 403) {
if (isMe(c.attributes)) {
throw error;
}
log.warn(
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, removing profileKey`
);
await c.setProfileKey(undefined, {
reason: 'doGetProfile/accessKey/401+403',
});
// Retry fetch using last known profileKeyVersion or fetch
// unversioned profile.
return doGetProfile(c);
}
if (error.code === 404) {
c.set('profileLastFetchedAt', Date.now());
await c.removeLastProfile(lastProfile);
}
throw error;
}
if (request.accessKey != null || request.groupSendToken != null) {
profile = await messaging.server.getProfileUnauth(serviceId, request);
} else {
try {
// We won't get the credential, but lets either fetch:
// - a versioned profile using last known profileKeyVersion
// - some basic profile information (capabilities, badges, etc).
profile = await messaging.getProfile(serviceId, getProfileOptions);
} catch (error) {
if (error instanceof HTTPError && error.code === 404) {
log.info(`getProfile: failed to find a profile for ${idForLogging}`);
c.set('profileLastFetchedAt', Date.now());
await c.removeLastProfile(lastProfile);
if (!isVersioned) {
log.info(`getProfile: marking ${idForLogging} as unregistered`);
c.setUnregistered();
}
}
throw error;
}
}
if (profile.identityKey) {
const identityKeyBytes = Bytes.fromBase64(profile.identityKey);
await updateIdentityKey(identityKeyBytes, serviceId);
}
// Update accessKey to prevent race conditions. Since we run asynchronous
// requests above - it is possible that someone updates or erases
// the profile key from under us.
accessKey = c.get('accessKey');
if (profile.unrestrictedUnidentifiedAccess && profile.unidentifiedAccess) {
log.info(
`getProfile: setting sealedSender to UNRESTRICTED for conversation ${idForLogging}`
strictAssert(
!isMe(c.attributes),
`${logId}: Should never fetch own profile on auth connection`
);
c.set({
sealedSender: SEALED_SENDER.UNRESTRICTED,
});
} else if (accessKey && profile.unidentifiedAccess) {
const haveCorrectKey = verifyAccessKey(
Bytes.fromBase64(accessKey),
Bytes.fromBase64(profile.unidentifiedAccess)
);
if (haveCorrectKey) {
log.info(
`getProfile: setting sealedSender to ENABLED for conversation ${idForLogging}`
);
c.set({
sealedSender: SEALED_SENDER.ENABLED,
});
} else {
log.warn(
`getProfile: setting sealedSender to DISABLED for conversation ${idForLogging}`
);
c.set({
sealedSender: SEALED_SENDER.DISABLED,
});
}
} else {
log.info(
`getProfile: setting sealedSender to DISABLED for conversation ${idForLogging}`
);
c.set({
sealedSender: SEALED_SENDER.DISABLED,
});
}
const rawDecryptionKey = c.get('profileKey') || lastProfile?.profileKey;
const decryptionKey = rawDecryptionKey
? Bytes.fromBase64(rawDecryptionKey)
: undefined;
if (profile.about) {
if (decryptionKey) {
const decrypted = decryptProfile(
Bytes.fromBase64(profile.about),
decryptionKey
);
c.set('about', Bytes.toString(trimForDisplay(decrypted)));
}
} else {
c.unset('about');
}
if (profile.aboutEmoji) {
if (decryptionKey) {
const decrypted = decryptProfile(
Bytes.fromBase64(profile.aboutEmoji),
decryptionKey
);
c.set('aboutEmoji', Bytes.toString(trimForDisplay(decrypted)));
}
} else {
c.unset('aboutEmoji');
}
if (profile.phoneNumberSharing) {
if (decryptionKey) {
const decrypted = decryptProfile(
Bytes.fromBase64(profile.phoneNumberSharing),
decryptionKey
);
// It should be one byte, but be conservative about it and
// set `sharingPhoneNumber` to `false` in all cases except [0x01].
c.set(
'sharingPhoneNumber',
decrypted.length === 1 && decrypted[0] === 1
);
}
} else {
c.unset('sharingPhoneNumber');
}
if (profile.paymentAddress && isMe(c.attributes)) {
await window.storage.put('paymentAddress', profile.paymentAddress);
}
const pastCapabilities = c.get('capabilities');
if (profile.capabilities) {
c.set({ capabilities: profile.capabilities });
} else {
c.unset('capabilities');
}
if (isMe(c.attributes)) {
const newCapabilities = c.get('capabilities');
let hasChanged = false;
const observedCapabilities = {
...window.storage.get('observedCapabilities'),
};
const newKeys = new Array<string>();
for (const key of OBSERVED_CAPABILITY_KEYS) {
// Already reported
if (observedCapabilities[key]) {
continue;
}
if (newCapabilities?.[key]) {
if (!pastCapabilities?.[key]) {
hasChanged = true;
newKeys.push(key);
}
observedCapabilities[key] = true;
}
}
await window.storage.put('observedCapabilities', observedCapabilities);
if (hasChanged) {
log.info(
'getProfile: detected a capability flip, sending fetch profile',
newKeys
);
await singleProtoJobQueue.add(
MessageSender.getFetchLocalProfileSyncMessage()
);
}
}
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
if (badges.length) {
await window.reduxActions.badges.updateOrCreate(badges);
c.set({
badges: badges.map(badge => ({
id: badge.id,
...('expiresAt' in badge
? {
expiresAt: badge.expiresAt,
isVisible: badge.isVisible,
}
: {}),
})),
});
} else {
c.unset('badges');
}
if (profileCredentialRequestContext) {
if (profile.credential) {
const {
credential: profileKeyCredential,
expiration: profileKeyCredentialExpiration,
} = handleProfileKeyCredential(
clientZkProfileCipher,
profileCredentialRequestContext,
profile.credential
);
c.set({ profileKeyCredential, profileKeyCredentialExpiration });
} else {
log.warn(
'getProfile: Included credential request, but got no credential. Clearing profileKeyCredential.'
);
c.unset('profileKeyCredential');
}
profile = await messaging.server.getProfile(serviceId, request);
}
} catch (error) {
if (!(error instanceof HTTPError)) {
throw error;
}
log.error(`${logId}: Failed to fetch profile`, Errors.toLogFormat(error));
switch (error.code) {
case 401:
case 403:
if (error instanceof HTTPError) {
// Unauthorized/Forbidden
if (error.code === 401 || error.code === 403) {
if (request.groupSendToken != null) {
onFailedToSendWithEndorsements(error);
}
// Step #: Retries for unauthorized access keys and group send tokens
if (!isMe(c.attributes)) {
// Fallback from failed unauth (access key) request
if (request.accessKey != null) {
log.warn(
`${logId}: Got ${error.code} when using access key, removing profileKey and retrying`
);
await c.setProfileKey(undefined, {
reason: 'doGetProfile/accessKey/401+403',
});
// Retry fetch using last known profileKeyVersion or fetch
// unversioned profile.
return doGetProfile(c, groupId);
}
// Fallback from failed unauth (group send token) request
if (request.groupSendToken != null) {
log.warn(`${logId}: Got ${error.code} when using group send token`);
return doGetProfile(c, null);
}
}
// Step #: Record if the accessKey we have in the conversation is valid
const sealedSender = c.get('sealedSender');
if (
c.get('sealedSender') === SEALED_SENDER.ENABLED ||
c.get('sealedSender') === SEALED_SENDER.UNRESTRICTED
sealedSender === SEALED_SENDER.ENABLED ||
sealedSender === SEALED_SENDER.UNRESTRICTED
) {
log.warn(
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, removing profileKey`
);
if (!isMe(c.attributes)) {
log.warn(
`${logId}: Got ${error.code} when using accessKey, removing profileKey`
);
await c.setProfileKey(undefined, {
reason: 'doGetProfile/accessKey/401+403',
});
}
}
if (c.get('sealedSender') === SEALED_SENDER.UNKNOWN) {
} else if (sealedSender === SEALED_SENDER.UNKNOWN) {
log.warn(
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, setting sealedSender = DISABLED`
`${logId}: Got ${error.code} fetching profile, setting sealedSender = DISABLED`
);
c.set('sealedSender', SEALED_SENDER.DISABLED);
}
// TODO: Is it safe to ignore these errors?
return;
default:
log.warn(
'getProfile failure:',
idForLogging,
isNumber(error.code)
? `code: ${error.code}`
: Errors.toLogFormat(error)
}
// Not Found
if (error.code === 404) {
log.info(`${logId}: Profile not found`);
c.set('profileLastFetchedAt', Date.now());
// Note: Writes to DB:
await c.removeLastProfile(lastProfile);
if (!isVersioned) {
log.info(`${logId}: Marking conversation unregistered`);
c.setUnregistered();
}
}
}
// throw all unhandled errors
throw error;
}
// Step #: Save `identityKey` to SignalProtocolStore
if (isFieldDefined(profile.identityKey)) {
const identityKeyBytes = Bytes.fromBase64(profile.identityKey);
// Note: Queues some jobs
await updateIdentityKey(identityKeyBytes, serviceId);
}
// Step #: Updating `sealedSender` based on the successful response
{
// Use the most up to date `accessKey` to prevent race conditions.
// Since we run asynchronous requests above - it is possible that someone
// updates or erases the profile key from under us.
const accessKey = c.get('accessKey');
let sealedSender: SEALED_SENDER;
if (isFieldDefined(profile.unidentifiedAccess)) {
if (isFieldDefined(profile.unrestrictedUnidentifiedAccess)) {
sealedSender = SEALED_SENDER.UNRESTRICTED;
} else if (accessKey != null) {
const haveCorrectKey = verifyAccessKey(
Bytes.fromBase64(accessKey),
Bytes.fromBase64(profile.unidentifiedAccess)
);
throw error;
if (haveCorrectKey) {
sealedSender = SEALED_SENDER.ENABLED;
} else {
log.info(
`${logId}: Access key mismatch with profile.unidentifiedAccess`
);
}
}
}
// Default to disabled if we don't have unrestricted access or the correct access key
sealedSender ??= SEALED_SENDER.DISABLED;
log.info(
`${logId}: setting sealedSender to ${SEALED_SENDER[sealedSender]} ` +
`(unidentifiedAccess: ${isFieldDefined(profile.unidentifiedAccess)}, ` +
`unrestrictedUnidentifiedAccess: ${isFieldDefined(profile.unrestrictedUnidentifiedAccess)}, ` +
`accessKey: ${accessKey != null})`
);
c.set({ sealedSender });
}
// Step #: Grab the current `profileKey` (which may have updated) or the last
// profile key we successfully decrypted from.
const rawRequestDecryptionKey = options.profileKey ?? lastProfile?.profileKey;
const rawUpdatedDecryptionKey =
c.get('profileKey') ?? lastProfile?.profileKey;
const requestDecryptionKey = rawRequestDecryptionKey
? Bytes.fromBase64(rawRequestDecryptionKey)
: null;
const updatedDecryptionKey = rawUpdatedDecryptionKey
? Bytes.fromBase64(rawUpdatedDecryptionKey)
: null;
// Step #: Save profile `about` to conversation
if (isFieldDefined(profile.about)) {
if (updatedDecryptionKey != null) {
const decrypted = decryptField(profile.about, updatedDecryptionKey);
c.set('about', formatTextField(decrypted));
}
} else {
c.unset('about');
}
// Step #: Save profile `aboutEmoji` to conversation
if (isFieldDefined(profile.aboutEmoji)) {
if (updatedDecryptionKey != null) {
const decrypted = decryptField(profile.aboutEmoji, updatedDecryptionKey);
c.set('aboutEmoji', formatTextField(decrypted));
}
} else {
c.unset('aboutEmoji');
}
// Step #: Save profile `phoneNumberSharing` to conversation
if (isFieldDefined(profile.phoneNumberSharing)) {
if (updatedDecryptionKey != null) {
const decrypted = decryptField(
profile.phoneNumberSharing,
updatedDecryptionKey
);
// It should be one byte, but be conservative about it and
// set `sharingPhoneNumber` to `false` in all cases except [0x01].
const sharingPhoneNumber = decrypted.length === 1 && decrypted[0] === 1;
c.set('sharingPhoneNumber', sharingPhoneNumber);
}
} else {
c.unset('sharingPhoneNumber');
}
// Step #: Save our own `paymentAddress` to Storage
if (isFieldDefined(profile.paymentAddress) && isMe(c.attributes)) {
await window.storage.put('paymentAddress', profile.paymentAddress);
}
// Step #: Save profile `capabilities` to conversation
const pastCapabilities = c.get('capabilities');
if (profile.capabilities != null) {
c.set({ capabilities: profile.capabilities });
} else {
c.unset('capabilities');
}
// Step #: Save our own `observedCapabilities` to Storage and trigger sync if changed
if (isMe(c.attributes)) {
const newCapabilities = c.get('capabilities');
let hasChanged = false;
const observedCapabilities = {
...window.storage.get('observedCapabilities'),
};
const newKeys = new Array<string>();
for (const key of OBSERVED_CAPABILITY_KEYS) {
// Already reported
if (observedCapabilities[key]) {
continue;
}
if (newCapabilities?.[key]) {
if (!pastCapabilities?.[key]) {
hasChanged = true;
newKeys.push(key);
}
observedCapabilities[key] = true;
}
}
await window.storage.put('observedCapabilities', observedCapabilities);
if (hasChanged) {
log.info(
'getProfile: detected a capability flip, sending fetch profile',
newKeys
);
await singleProtoJobQueue.add(
MessageSender.getFetchLocalProfileSyncMessage()
);
}
}
const decryptionKeyString = profileKey || lastProfile?.profileKey;
const decryptionKey = decryptionKeyString
? Bytes.fromBase64(decryptionKeyString)
: undefined;
// Step #: Save profile `badges` to conversation and update redux
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
if (badges.length) {
window.reduxActions.badges.updateOrCreate(badges);
c.set({
badges: badges.map(badge => ({
id: badge.id,
...('expiresAt' in badge
? {
expiresAt: badge.expiresAt,
isVisible: badge.isVisible,
}
: {}),
})),
});
} else {
c.unset('badges');
}
// Step #: Save updated (or clear if missing) profile `credential` to conversation
if (options.profileCredentialRequestContext != null) {
if (profile.credential != null && profile.credential.length > 0) {
const {
credential: profileKeyCredential,
expiration: profileKeyCredentialExpiration,
} = handleProfileKeyCredential(
clientZkProfileCipher,
options.profileCredentialRequestContext,
profile.credential
);
c.set({ profileKeyCredential, profileKeyCredentialExpiration });
} else {
log.warn(
`${logId}: Included credential request, but got no credential. Clearing profileKeyCredential.`
);
c.unset('profileKeyCredential');
}
}
// TODO: Should this track other failures?
let isSuccessfullyDecrypted = true;
if (profile.name) {
if (decryptionKey) {
// Step #: Save profile `name` to conversation
if (isFieldDefined(profile.name)) {
if (requestDecryptionKey != null) {
try {
await c.setEncryptedProfileName(profile.name, decryptionKey);
// Note: Writes to DB and saves message
await c.setEncryptedProfileName(profile.name, requestDecryptionKey);
} catch (error) {
log.warn(
'getProfile decryption failure:',
idForLogging,
`${logId}: Failed to decrypt profile name`,
Errors.toLogFormat(error)
);
isSuccessfullyDecrypted = false;
await c.set({
c.set({
profileName: undefined,
profileFamilyName: undefined,
});
@ -615,19 +796,22 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
}
try {
if (decryptionKey) {
await c.setProfileAvatar(profile.avatar, decryptionKey);
if (requestDecryptionKey != null) {
// Note: Fetches avatar
await c.setAndMaybeFetchProfileAvatar(
profile.avatar,
requestDecryptionKey
);
}
} catch (error) {
if (error instanceof HTTPError) {
// Forbidden/Not Found
if (error.code === 403 || error.code === 404) {
log.warn(
`getProfile: profile avatar is missing for conversation ${idForLogging}`
);
log.warn(`${logId}: Profile avatar is missing (${error.code})`);
}
} else {
log.warn(
`getProfile: failed to decrypt avatar for conversation ${idForLogging}`,
`${logId}: Failed to decrypt profile avatar`,
Errors.toLogFormat(error)
);
isSuccessfullyDecrypted = false;
@ -639,12 +823,12 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
// After we successfully decrypted - update lastProfile property
if (
isSuccessfullyDecrypted &&
profileKey &&
getProfileOptions.profileKeyVersion
options.profileKey &&
request.profileKeyVersion
) {
await c.updateLastProfile(lastProfile, {
profileKey,
profileKeyVersion: getProfileOptions.profileKeyVersion,
profileKey: options.profileKey,
profileKeyVersion: request.profileKeyVersion,
});
}

View file

@ -1395,7 +1395,7 @@ export async function mergeAccountRecord(
: PhoneNumberDiscoverability.Discoverable;
await window.storage.put('phoneNumberDiscoverability', discoverability);
if (profileKey) {
if (profileKey && profileKey.byteLength > 0) {
void ourProfileKeyService.set(profileKey);
}
@ -1657,14 +1657,14 @@ export async function mergeAccountRecord(
});
let needsProfileFetch = false;
if (profileKey && profileKey.length > 0) {
if (profileKey && profileKey.byteLength > 0) {
needsProfileFetch = await conversation.setProfileKey(
Bytes.toBase64(profileKey),
{ viaStorageServiceSync: true, reason: 'mergeAccountRecord' }
);
const avatarUrl = dropNull(accountRecord.avatarUrl);
await conversation.setProfileAvatar(avatarUrl, profileKey);
await conversation.setAndMaybeFetchProfileAvatar(avatarUrl, profileKey);
await window.storage.put('avatarUrl', avatarUrl);
}

View file

@ -127,7 +127,11 @@ class UsernameIntegrityService {
private async checkPhoneNumberSharing(): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow();
await getProfile(me.getServiceId(), me.get('e164'));
await getProfile({
serviceId: me.getServiceId() ?? null,
e164: me.get('e164') ?? null,
groupId: null,
});
{
const localValue = isSharingPhoneNumberWithEverybody();

View file

@ -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 {

View file

@ -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 */

View file

@ -11,10 +11,14 @@ import { generateAci } from '../types/ServiceId';
import { DAY, HOUR, MINUTE, MONTH } from '../util/durations';
import { routineProfileRefresh } from '../routineProfileRefresh';
import type { getProfile } from '../util/getProfile';
describe('routineProfileRefresh', () => {
let sinonSandbox: sinon.SinonSandbox;
let getProfileFn: sinon.SinonStub;
let getProfileFn: sinon.SinonStub<
Parameters<typeof getProfile>,
ReturnType<typeof getProfile>
>;
beforeEach(() => {
sinonSandbox = sinon.createSandbox();
@ -111,16 +115,16 @@ describe('routineProfileRefresh', () => {
id: 1,
});
sinon.assert.calledWith(
getProfileFn,
conversation1.getServiceId(),
conversation1.get('e164')
);
sinon.assert.calledWith(
getProfileFn,
conversation2.getServiceId(),
conversation2.get('e164')
);
sinon.assert.calledWith(getProfileFn, {
serviceId: conversation1.getServiceId() ?? null,
e164: conversation1.get('e164') ?? null,
groupId: null,
});
sinon.assert.calledWith(getProfileFn, {
serviceId: conversation2.getServiceId() ?? null,
e164: conversation2.get('e164') ?? null,
groupId: null,
});
});
it('skips unregistered conversations and those fetched in the last three days', async () => {
@ -141,21 +145,21 @@ describe('routineProfileRefresh', () => {
});
sinon.assert.calledOnce(getProfileFn);
sinon.assert.calledWith(
getProfileFn,
normal.getServiceId(),
normal.get('e164')
);
sinon.assert.neverCalledWith(
getProfileFn,
recentlyFetched.getServiceId(),
recentlyFetched.get('e164')
);
sinon.assert.neverCalledWith(
getProfileFn,
unregisteredAndStale.getServiceId(),
unregisteredAndStale.get('e164')
);
sinon.assert.calledWith(getProfileFn, {
serviceId: normal.getServiceId() ?? null,
e164: normal.get('e164') ?? null,
groupId: null,
});
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: recentlyFetched.getServiceId() ?? null,
e164: recentlyFetched.get('e164') ?? null,
groupId: null,
});
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: unregisteredAndStale.getServiceId() ?? null,
e164: unregisteredAndStale.get('e164') ?? null,
groupId: null,
});
});
it('skips your own conversation', async () => {
@ -170,16 +174,16 @@ describe('routineProfileRefresh', () => {
id: 1,
});
sinon.assert.calledWith(
getProfileFn,
notMe.getServiceId(),
notMe.get('e164')
);
sinon.assert.neverCalledWith(
getProfileFn,
me.getServiceId(),
me.get('e164')
);
sinon.assert.calledWith(getProfileFn, {
serviceId: notMe.getServiceId() ?? null,
e164: notMe.get('e164') ?? null,
groupId: null,
});
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: me.getServiceId() ?? null,
e164: me.get('e164') ?? null,
groupId: null,
});
});
it('includes your own conversation if profileKeyCredential is expired', async () => {
@ -198,12 +202,16 @@ describe('routineProfileRefresh', () => {
id: 1,
});
sinon.assert.calledWith(
getProfileFn,
notMe.getServiceId(),
notMe.get('e164')
);
sinon.assert.calledWith(getProfileFn, me.getServiceId(), me.get('e164'));
sinon.assert.calledWith(getProfileFn, {
serviceId: notMe.getServiceId() ?? null,
e164: notMe.get('e164') ?? null,
groupId: null,
});
sinon.assert.calledWith(getProfileFn, {
serviceId: me.getServiceId() ?? null,
e164: me.get('e164') ?? null,
groupId: null,
});
});
it('skips conversations that were refreshed in last three days', async () => {
@ -236,31 +244,31 @@ describe('routineProfileRefresh', () => {
});
sinon.assert.calledTwice(getProfileFn);
sinon.assert.calledWith(
getProfileFn,
neverRefreshed.getServiceId(),
neverRefreshed.get('e164')
);
sinon.assert.neverCalledWith(
getProfileFn,
refreshedToday.getServiceId(),
refreshedToday.get('e164')
);
sinon.assert.neverCalledWith(
getProfileFn,
refreshedYesterday.getServiceId(),
refreshedYesterday.get('e164')
);
sinon.assert.neverCalledWith(
getProfileFn,
refreshedTwoDaysAgo.getServiceId(),
refreshedTwoDaysAgo.get('e164')
);
sinon.assert.calledWith(
getProfileFn,
refreshedThreeDaysAgo.getServiceId(),
refreshedThreeDaysAgo.get('e164')
);
sinon.assert.calledWith(getProfileFn, {
serviceId: neverRefreshed.getServiceId() ?? null,
e164: neverRefreshed.get('e164') ?? null,
groupId: null,
});
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: refreshedToday.getServiceId() ?? null,
e164: refreshedToday.get('e164') ?? null,
groupId: null,
});
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: refreshedYesterday.getServiceId() ?? null,
e164: refreshedYesterday.get('e164') ?? null,
groupId: null,
});
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: refreshedTwoDaysAgo.getServiceId() ?? null,
e164: refreshedTwoDaysAgo.get('e164') ?? null,
groupId: null,
});
sinon.assert.calledWith(getProfileFn, {
serviceId: refreshedThreeDaysAgo.getServiceId() ?? null,
e164: refreshedThreeDaysAgo.get('e164') ?? null,
groupId: null,
});
});
it('only refreshes profiles for the 50 conversations with the oldest profileLastFetchedAt', async () => {
@ -300,19 +308,19 @@ describe('routineProfileRefresh', () => {
});
[...normalConversations, ...neverFetched].forEach(conversation => {
sinon.assert.calledWith(
getProfileFn,
conversation.getServiceId(),
conversation.get('e164')
);
sinon.assert.calledWith(getProfileFn, {
serviceId: conversation.getServiceId() ?? null,
e164: conversation.get('e164') ?? null,
groupId: null,
});
});
[me, ...shouldNotBeIncluded].forEach(conversation => {
sinon.assert.neverCalledWith(
getProfileFn,
conversation.getServiceId(),
conversation.get('e164')
);
sinon.assert.neverCalledWith(getProfileFn, {
serviceId: conversation.getServiceId() ?? null,
e164: conversation.get('e164') ?? null,
groupId: null,
});
});
});
});

View file

@ -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');

View file

@ -0,0 +1,92 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as sinon from 'sinon';
import assert from 'node:assert/strict';
import type { MapEmplaceOptions } from '../../util/mapEmplace';
import { mapEmplace } from '../../util/mapEmplace';
type InsertFn = NonNullable<MapEmplaceOptions<Map<object, object>>['insert']>;
type UpdateFn = NonNullable<MapEmplaceOptions<Map<object, object>>['update']>;
describe('mapEmplace', () => {
it('should insert and not update when key not present', () => {
const map = new Map<object, object>();
const key = { key: true };
const insertValue = { value: 'insertValue' };
const updateValue = { value: 'updateValue' };
const insert = sinon.spy<InsertFn>(() => insertValue);
const update = sinon.spy<UpdateFn>(() => updateValue);
const resultValue = mapEmplace(map, key, { insert, update });
assert.equal(resultValue, insertValue);
assert.equal(map.get(key), insertValue);
assert.equal(insert.callCount, 1);
assert.equal(insert.calledWithExactly(key, map), true);
assert.equal(update.callCount, 0);
});
it('should not insert when key present', () => {
const map = new Map<object, object>();
const key = { key: true };
const currentValue = { value: 'currentValue' };
const insertValue = { value: 'insertValue' };
const insert = sinon.spy<InsertFn>(() => insertValue);
map.set(key, currentValue);
const resultValue = mapEmplace(map, key, { insert });
assert.equal(resultValue, currentValue);
assert.equal(map.get(key), currentValue);
assert.equal(insert.callCount, 0);
});
it('should update when key present', () => {
const map = new Map<object, object>();
const key = { key: true };
const currentValue = { value: 'currentValue' };
const insertValue = { value: 'insertValue' };
const updateValue = { value: 'updateValue' };
const insert = sinon.spy<InsertFn>(() => insertValue);
const update = sinon.spy<UpdateFn>(() => updateValue);
map.set(key, currentValue);
const resultValue = mapEmplace(map, key, { insert, update });
assert.equal(resultValue, updateValue);
assert.equal(map.get(key), updateValue);
assert.equal(insert.callCount, 0);
assert.equal(update.callCount, 1);
assert.equal(update.calledWithExactly(currentValue, key, map), true);
});
it('should throw when key not present and no insert provided', () => {
const map = new Map<object, object>();
const key = { key: true };
const updateValue = { value: 'updateValue' };
const update = sinon.spy<UpdateFn>(() => updateValue);
assert.throws(() => {
mapEmplace(map, key, { update });
});
assert.equal(map.has(key), false);
assert.equal(update.callCount, 0);
});
it('should return value unmodified when update not provided', () => {
const map = new Map<object, object>();
const key = { key: true };
const currentValue = { value: 'currentValue' };
const insertValue = { value: 'insertValue' };
const insert = sinon.spy<InsertFn>(() => insertValue);
map.set(key, currentValue);
const resultValue = mapEmplace(map, key, { insert });
assert.equal(resultValue, currentValue);
assert.equal(map.get(key), currentValue);
assert.equal(insert.callCount, 0);
});
});

View file

@ -42,6 +42,7 @@ import { Sessions, IdentityKeys } from '../LibSignalStores';
import { getKeysForServiceId } from './getKeysForServiceId';
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
export const enum SenderCertificateMode {
WithE164,
@ -306,13 +307,20 @@ export default class OutgoingMessage {
serviceId: ServiceIdString,
jsonData: ReadonlyArray<MessageType>,
timestamp: number,
{ accessKey }: { accessKey?: string } = {}
{
accessKey,
groupSendToken,
}: {
accessKey: string | null;
groupSendToken: GroupSendToken | null;
} = { accessKey: null, groupSendToken: null }
): Promise<void> {
let promise;
if (accessKey) {
if (accessKey != null || groupSendToken != null) {
promise = this.server.sendMessagesUnauth(serviceId, jsonData, timestamp, {
accessKey,
groupSendToken,
online: this.online,
story: this.story,
urgent: this.urgent,
@ -393,7 +401,11 @@ export default class OutgoingMessage {
recurse?: boolean
): Promise<void> {
const { sendMetadata } = this;
const { accessKey, senderCertificate } = sendMetadata?.[serviceId] || {};
const {
accessKey = null,
groupSendToken = null,
senderCertificate,
} = sendMetadata?.[serviceId] || {};
if (accessKey && !senderCertificate) {
log.warn(
@ -401,7 +413,9 @@ export default class OutgoingMessage {
);
}
const sealedSender = Boolean(accessKey && senderCertificate);
const sealedSender =
(accessKey != null || groupSendToken != null) &&
senderCertificate != null;
// We don't send to ourselves unless sealedSender is enabled
const ourNumber = window.textsecure.storage.user.getNumber();
@ -508,6 +522,7 @@ export default class OutgoingMessage {
if (sealedSender) {
return this.transmitMessage(serviceId, jsonData, this.timestamp, {
accessKey,
groupSendToken,
}).then(
() => {
this.recipients[serviceId] = deviceIds;

View file

@ -36,8 +36,6 @@ import {
import type {
ChallengeType,
GetGroupLogOptionsType,
GetProfileOptionsType,
GetProfileUnauthOptionsType,
GroupCredentialsType,
GroupLogResponseType,
ProxiedRequestOptionsType,
@ -103,12 +101,22 @@ import {
getProtoForCallHistory,
} from '../util/callDisposition';
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
export type SendIdentifierData =
| {
accessKey: string;
senderCertificate: SerializedCertificateType | null;
groupSendToken: null;
}
| {
accessKey: null;
senderCertificate: SerializedCertificateType | null;
groupSendToken: GroupSendToken;
};
export type SendMetadataType = {
[serviceId: ServiceIdString]: {
accessKey: string;
senderCertificate?: SerializedCertificateType;
};
[serviceId: ServiceIdString]: SendIdentifierData;
};
export type SendOptionsType = {
@ -2321,17 +2329,6 @@ export default class MessageSender {
// Note: instead of updating these functions, or adding new ones, remove these and go
// directly to window.textsecure.messaging.server.<function>
async getProfile(
serviceId: ServiceIdString,
options: GetProfileOptionsType | GetProfileUnauthOptionsType
): ReturnType<WebAPIType['getProfile']> {
if (options.accessKey !== undefined) {
return this.server.getProfileUnauth(serviceId, options);
}
return this.server.getProfile(serviceId, options);
}
async getAvatar(path: string): Promise<ReturnType<WebAPIType['getAvatar']>> {
return this.server.getAvatar(path);
}

View file

@ -74,6 +74,10 @@ import { isStagingServer } from '../util/isStagingServer';
import type { IWebSocketResource } from './WebsocketResources';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
import { parseUnknown, safeParseUnknown } from '../util/schemas';
import type {
ProfileFetchAuthRequestOptions,
ProfileFetchUnauthRequestOptions,
} from '../services/profiles';
// Note: this will break some code that expects to be able to use err.response when a
// web request fails, because it will force it to text. But it is very useful for
@ -91,7 +95,7 @@ function resolveLibsignalNetEnvironment(url: string): Net.Environment {
}
function _createRedactor(
...toReplace: ReadonlyArray<string | undefined>
...toReplace: ReadonlyArray<string | undefined | null>
): RedactUrl {
// NOTE: It would be nice to remove this cast, but TypeScript doesn't support
// it. However, there is [an issue][0] that discusses this in more detail.
@ -898,31 +902,6 @@ export type CdsLookupOptionsType = Readonly<{
useLibsignal?: boolean;
}>;
type GetProfileCommonOptionsType = Readonly<
{
userLanguages: ReadonlyArray<string>;
} & (
| {
profileKeyVersion?: undefined;
profileKeyCredentialRequest?: undefined;
}
| {
profileKeyVersion: string;
profileKeyCredentialRequest?: string;
}
)
>;
export type GetProfileOptionsType = GetProfileCommonOptionsType &
Readonly<{
accessKey?: undefined;
}>;
export type GetProfileUnauthOptionsType = GetProfileCommonOptionsType &
Readonly<{
accessKey: string;
}>;
export type GetGroupCredentialsOptionsType = Readonly<{
startDayInMs: number;
endDayInMs: number;
@ -1311,14 +1290,14 @@ export type WebAPIType = {
}>;
getProfile: (
serviceId: ServiceIdString,
options: GetProfileOptionsType
options: ProfileFetchAuthRequestOptions
) => Promise<ProfileType>;
getAccountForUsername: (
options: GetAccountForUsernameOptionsType
) => Promise<GetAccountForUsernameResultType>;
getProfileUnauth: (
serviceId: ServiceIdString,
options: GetProfileUnauthOptionsType
options: ProfileFetchUnauthRequestOptions
) => Promise<ProfileType>;
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
getSubscriptionConfiguration: (
@ -1413,7 +1392,8 @@ export type WebAPIType = {
messageArray: ReadonlyArray<MessageType>,
timestamp: number,
options: {
accessKey?: string;
accessKey: string | null;
groupSendToken: GroupSendToken | null;
online?: boolean;
story?: boolean;
urgent?: boolean;
@ -1421,8 +1401,8 @@ export type WebAPIType = {
) => Promise<void>;
sendWithSenderKey: (
payload: Uint8Array,
accessKeys: Uint8Array | undefined,
groupSendToken: GroupSendToken | undefined,
accessKeys: Uint8Array | null,
groupSendToken: GroupSendToken | null,
timestamp: number,
options: {
online?: boolean;
@ -2177,19 +2157,19 @@ export function initialize({
{
profileKeyVersion,
profileKeyCredentialRequest,
}: GetProfileCommonOptionsType
}: ProfileFetchAuthRequestOptions | ProfileFetchUnauthRequestOptions
) {
let profileUrl = `/${serviceId}`;
if (profileKeyVersion !== undefined) {
if (profileKeyVersion != null) {
profileUrl += `/${profileKeyVersion}`;
if (profileKeyCredentialRequest !== undefined) {
if (profileKeyCredentialRequest != null) {
profileUrl +=
`/${profileKeyCredentialRequest}` +
'?credentialType=expiringProfileKey';
}
} else {
strictAssert(
profileKeyCredentialRequest === undefined,
profileKeyCredentialRequest == null,
'getProfileUrl called without version, but with request'
);
}
@ -2199,7 +2179,7 @@ export function initialize({
async function getProfile(
serviceId: ServiceIdString,
options: GetProfileOptionsType
options: ProfileFetchAuthRequestOptions
) {
const { profileKeyVersion, profileKeyCredentialRequest, userLanguages } =
options;
@ -2258,15 +2238,25 @@ export function initialize({
async function getProfileUnauth(
serviceId: ServiceIdString,
options: GetProfileUnauthOptionsType
options: ProfileFetchUnauthRequestOptions
) {
const {
accessKey,
groupSendToken,
profileKeyVersion,
profileKeyCredentialRequest,
userLanguages,
} = options;
if (profileKeyVersion != null || profileKeyCredentialRequest != null) {
// Without an up-to-date profile key, we won't be able to read the
// profile anyways so there's no point in falling back to endorsements.
strictAssert(
groupSendToken == null,
'Should not use endorsements for fetching a versioned profile'
);
}
return (await _ajax({
call: 'profile',
httpType: 'GET',
@ -2276,8 +2266,8 @@ export function initialize({
},
responseType: 'json',
unauthenticated: true,
accessKey,
groupSendToken: undefined,
accessKey: accessKey ?? undefined,
groupSendToken: groupSendToken ?? undefined,
redactUrl: _createRedactor(
serviceId,
profileKeyVersion,
@ -3273,11 +3263,13 @@ export function initialize({
timestamp: number,
{
accessKey,
groupSendToken,
online,
urgent = true,
story = false,
}: {
accessKey?: string;
accessKey: string | null;
groupSendToken: GroupSendToken | null;
online?: boolean;
story?: boolean;
urgent?: boolean;
@ -3297,8 +3289,8 @@ export function initialize({
jsonData,
responseType: 'json',
unauthenticated: true,
accessKey,
groupSendToken: undefined,
accessKey: accessKey ?? undefined,
groupSendToken: groupSendToken ?? undefined,
});
}
@ -3334,8 +3326,8 @@ export function initialize({
async function sendWithSenderKey(
data: Uint8Array,
accessKeys: Uint8Array | undefined,
groupSendToken: GroupSendToken | undefined,
accessKeys: Uint8Array | null,
groupSendToken: GroupSendToken | null,
timestamp: number,
{
online,
@ -3360,7 +3352,7 @@ export function initialize({
responseType: 'json',
unauthenticated: true,
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
groupSendToken,
groupSendToken: groupSendToken ?? undefined,
});
const parseResult = safeParseUnknown(
multiRecipient200ResponseSchema,

View file

@ -5,10 +5,15 @@ import * as log from '../logging/log';
import { profileService } from '../services/profiles';
import type { ServiceIdString } from '../types/ServiceId';
export async function getProfile(
serviceId?: ServiceIdString,
e164?: string
): Promise<void> {
export async function getProfile({
serviceId,
e164,
groupId,
}: {
serviceId: ServiceIdString | null;
e164: string | null;
groupId: string | null;
}): Promise<void> {
const c = window.ConversationController.lookupOrCreate({
serviceId,
e164,
@ -19,5 +24,5 @@ export async function getProfile(
return;
}
return profileService.get(c.id);
return profileService.get(c.id, groupId);
}

View file

@ -3,6 +3,7 @@
import type { ConversationAttributesType } from '../model-types.d';
import type {
SendIdentifierData,
SendMetadataType,
SendOptionsType,
} from '../textsecure/SendMessage';
@ -15,6 +16,7 @@ import { shouldSharePhoneNumberWith } from './phoneNumberSharingMode';
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
import { isNotNil } from './isNotNil';
import { maybeCreateGroupSendEndorsementState } from './groupSendEndorsements';
const SEALED_SENDER = {
UNKNOWN: 0,
@ -61,9 +63,10 @@ export async function getSendOptionsForRecipients(
export async function getSendOptions(
conversationAttrs: ConversationAttributesType,
options: { syncMessage?: boolean; story?: boolean } = {}
options: { syncMessage?: boolean; story?: boolean; groupId?: string } = {},
alreadyRefreshedGroupState = false
): Promise<SendOptionsType> {
const { syncMessage, story } = options;
const { syncMessage, story, groupId } = options;
if (!isDirectConversation(conversationAttrs)) {
const contactCollection = getConversationMembers(conversationAttrs);
@ -84,8 +87,6 @@ export async function getSendOptions(
return { sendMetadata };
}
const { accessKey, sealedSender } = conversationAttrs;
// We never send sync messages or to our own account as sealed sender
if (syncMessage || isMe(conversationAttrs)) {
return {
@ -93,54 +94,77 @@ export async function getSendOptions(
};
}
const { accessKey, sealedSender } = conversationAttrs;
const { e164, serviceId } = conversationAttrs;
const senderCertificate =
await getSenderCertificateForDirectConversation(conversationAttrs);
let identifierData: SendIdentifierData | null = null;
// If we've never fetched user's profile, we default to what we have
if (sealedSender === SEALED_SENDER.UNKNOWN || story) {
const identifierData = {
identifierData = {
accessKey:
accessKey ||
(story
? Bytes.toBase64(getZeroes(16))
: Bytes.toBase64(getRandomBytes(16))),
senderCertificate,
};
return {
sendMetadata: {
...(e164 ? { [e164]: identifierData } : {}),
...(serviceId ? { [serviceId]: identifierData } : {}),
},
groupSendToken: null,
};
}
if (sealedSender === SEALED_SENDER.DISABLED) {
return {
sendMetadata: undefined,
if (serviceId != null && groupId != null) {
const { state: groupSendEndorsementState, didRefreshGroupState } =
await maybeCreateGroupSendEndorsementState(
groupId,
alreadyRefreshedGroupState
);
if (
groupSendEndorsementState != null &&
groupSendEndorsementState.hasMember(serviceId)
) {
const token = groupSendEndorsementState.buildToken(
new Set([serviceId])
);
if (token != null) {
identifierData = {
accessKey: null,
senderCertificate,
groupSendToken: token,
};
}
} else if (didRefreshGroupState && !alreadyRefreshedGroupState) {
return getSendOptions(conversationAttrs, options, true);
}
}
} else {
identifierData = {
accessKey:
accessKey && sealedSender === SEALED_SENDER.ENABLED
? accessKey
: Bytes.toBase64(getRandomBytes(16)),
senderCertificate,
groupSendToken: null,
};
}
const identifierData = {
accessKey:
accessKey && sealedSender === SEALED_SENDER.ENABLED
? accessKey
: Bytes.toBase64(getRandomBytes(16)),
senderCertificate,
};
return {
sendMetadata: {
let sendMetadata: SendMetadataType = {};
if (identifierData != null) {
sendMetadata = {
...(e164 ? { [e164]: identifierData } : {}),
...(serviceId ? { [serviceId]: identifierData } : {}),
},
};
};
}
return { sendMetadata };
}
function getSenderCertificateForDirectConversation(
async function getSenderCertificateForDirectConversation(
conversationAttrs: ConversationAttributesType
): Promise<undefined | SerializedCertificateType> {
): Promise<SerializedCertificateType | null> {
if (!isDirectConversation(conversationAttrs)) {
throw new Error(
'getSenderCertificateForDirectConversation should only be called for direct conversations'
@ -154,5 +178,5 @@ function getSenderCertificateForDirectConversation(
certificateMode = SenderCertificateMode.WithoutE164;
}
return senderCertificateService.get(certificateMode);
return (await senderCertificateService.get(certificateMode)) ?? null;
}

View file

@ -18,7 +18,7 @@ import {
GroupSendEndorsementsResponse,
ServerPublicParams,
} from './zkgroup';
import type { AciString, ServiceIdString } from '../types/ServiceId';
import type { ServiceIdString } from '../types/ServiceId';
import { fromAciObject } from '../types/ServiceId';
import * as log from '../logging/log';
import type { GroupV2MemberType } from '../model-types';
@ -28,6 +28,9 @@ import * as Errors from '../types/errors';
import { isTestOrMockEnvironment } from '../environment';
import { isAlpha } from './version';
import { parseStrict } from './schemas';
import { DataReader } from '../sql/Client';
import { maybeUpdateGroup } from '../groups';
import { isGroupV2 } from './whatTypeOfConversation';
export function decodeGroupSendEndorsementResponse({
groupId,
@ -136,12 +139,13 @@ export function isValidGroupSendEndorsementsExpiration(
export class GroupSendEndorsementState {
#combinedEndorsement: GroupSendCombinedEndorsementRecord;
#otherMemberEndorsements = new Map<
#memberEndorsements = new Map<
ServiceIdString,
GroupSendMemberEndorsementRecord
>();
#otherMemberEndorsementsAcis = new Set<AciString>();
#memberEndorsementsAcis = new Set<ServiceIdString>();
#groupSecretParamsBase64: string;
#ourAci: ServiceIdString;
#endorsementCache = new WeakMap<Uint8Array, GroupSendEndorsement>();
constructor(
@ -150,13 +154,10 @@ export class GroupSendEndorsementState {
) {
this.#combinedEndorsement = data.combinedEndorsement;
this.#groupSecretParamsBase64 = groupSecretParamsBase64;
const ourAci = window.textsecure.storage.user.getCheckedAci();
this.#ourAci = window.textsecure.storage.user.getCheckedAci();
for (const endorsement of data.memberEndorsements) {
if (endorsement.memberAci !== ourAci) {
this.#otherMemberEndorsements.set(endorsement.memberAci, endorsement);
this.#otherMemberEndorsementsAcis.add(endorsement.memberAci);
}
this.#memberEndorsements.set(endorsement.memberAci, endorsement);
this.#memberEndorsementsAcis.add(endorsement.memberAci);
}
}
@ -171,10 +172,10 @@ export class GroupSendEndorsementState {
}
hasMember(serviceId: ServiceIdString): boolean {
return this.#otherMemberEndorsements.has(serviceId);
return this.#memberEndorsements.has(serviceId);
}
#toEndorsement(contents: Uint8Array) {
#toEndorsement(contents: Uint8Array): GroupSendEndorsement {
let endorsement = this.#endorsementCache.get(contents);
if (endorsement == null) {
endorsement = new GroupSendEndorsement(Buffer.from(contents));
@ -183,73 +184,7 @@ export class GroupSendEndorsementState {
return endorsement;
}
// Strategy 1: Faster when we're sending to most of the group members
// `combined.byRemoving(combine(difference(members, sends)))`
#subtractMemberEndorsements(
difference: Set<ServiceIdString>
): GroupSendEndorsement {
const toRemove: Array<GroupSendEndorsement> = [];
for (const serviceId of difference) {
const memberEndorsement = this.#otherMemberEndorsements.get(serviceId);
strictAssert(
memberEndorsement,
'serializeGroupSendEndorsementFullToken: Missing endorsement'
);
toRemove.push(this.#toEndorsement(memberEndorsement.endorsement));
}
return this.#toEndorsement(
this.#combinedEndorsement.endorsement
).byRemoving(GroupSendEndorsement.combine(toRemove));
}
// Strategy 2: Faster when we're not sending to most of the group members
// `combine(sends)`
#combineMemberEndorsements(
serviceIds: Set<ServiceIdString>
): GroupSendEndorsement {
const memberEndorsements = Array.from(serviceIds).map(serviceId => {
const memberEndorsement = this.#otherMemberEndorsements.get(serviceId);
strictAssert(
memberEndorsement,
'serializeGroupSendEndorsementFullToken: Missing endorsement'
);
return this.#toEndorsement(memberEndorsement.endorsement);
});
return GroupSendEndorsement.combine(memberEndorsements);
}
buildToken(serviceIds: Set<ServiceIdString>): GroupSendToken {
const sendCount = serviceIds.size;
const otherMemberCount = this.#otherMemberEndorsements.size;
const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${otherMemberCount})`;
const missing = serviceIds.difference(this.#otherMemberEndorsementsAcis);
if (missing.size !== 0) {
throw new Error(
`Attempted to build token with memberAcis we don't have endorsements for (${logServiceIds(missing)})`
);
}
const difference = this.#otherMemberEndorsementsAcis.difference(serviceIds);
log.info(
`buildToken: Endorsements without sends ${difference.size}: ${logServiceIds(difference)})`
);
let endorsement: GroupSendEndorsement;
if (difference.size === 0) {
log.info(`${logId}: combinedEndorsement`);
endorsement = this.#toEndorsement(this.#combinedEndorsement.endorsement);
} else if (difference.size < otherMemberCount / 2) {
log.info(`${logId}: subtractMemberEndorsements`);
endorsement = this.#subtractMemberEndorsements(difference);
} else {
log.info(`${logId}: combineMemberEndorsements`);
endorsement = this.#combineMemberEndorsements(serviceIds);
}
#toToken(endorsement: GroupSendEndorsement): GroupSendToken {
const groupSecretParams = new GroupSecretParams(
Buffer.from(this.#groupSecretParamsBase64, 'base64')
);
@ -264,6 +199,105 @@ export class GroupSendEndorsementState {
const fullToken = endorsement.toFullToken(groupSecretParams, expiration);
return toGroupSendToken(fullToken.serialize());
}
#getCombinedEndorsement(includesOurs: boolean) {
const endorsement = this.#toEndorsement(
this.#combinedEndorsement.endorsement
);
if (!includesOurs) {
return endorsement;
}
return GroupSendEndorsement.combine([
endorsement,
this.#getMemberEndorsement(this.#ourAci),
]);
}
#getMemberEndorsement(serviceId: ServiceIdString) {
const memberEndorsement = this.#memberEndorsements.get(serviceId);
strictAssert(
memberEndorsement,
'subtractMemberEndorsements: Missing endorsement'
);
return this.#toEndorsement(memberEndorsement.endorsement);
}
// Strategy 1: Faster when we're sending to most of the group members
// `combined.byRemoving(combine(difference(members, sends)))`
#subtractMemberEndorsements(
otherMembersServiceIds: Set<ServiceIdString>,
includesOurs: boolean
): GroupSendEndorsement {
strictAssert(
!otherMembersServiceIds.has(this.#ourAci),
'subtractMemberEndorsements: Cannot subtract our own aci from the combined endorsement'
);
return this.#getCombinedEndorsement(includesOurs).byRemoving(
this.#combineMemberEndorsements(otherMembersServiceIds)
);
}
// Strategy 2: Faster when we're not sending to most of the group members
// `combine(sends)`
#combineMemberEndorsements(
serviceIds: Set<ServiceIdString>
): GroupSendEndorsement {
return GroupSendEndorsement.combine(
Array.from(serviceIds, serviceId => {
return this.#getMemberEndorsement(serviceId);
})
);
}
#buildToken(serviceIds: Set<ServiceIdString>): GroupSendEndorsement {
const sendCount = serviceIds.size;
const memberCount = this.#memberEndorsements.size;
const logId = `GroupSendEndorsementState.buildToken(${sendCount} of ${memberCount})`;
// Fast path sending to one person
if (serviceIds.size === 1) {
log.info(`${logId}: using single member endorsement`);
const [serviceId] = serviceIds;
return this.#getMemberEndorsement(serviceId);
}
const missing = serviceIds.difference(this.#memberEndorsementsAcis);
if (missing.size !== 0) {
throw new Error(
`${logId}: Attempted to build token with memberAcis we don't have endorsements for (${logServiceIds(missing)})`
);
}
const difference = this.#memberEndorsementsAcis.difference(serviceIds);
log.info(
`${logId}: Endorsements without sends ${difference.size}: ${logServiceIds(difference)}`
);
const otherMembers = new Set(difference);
const includesOurs = otherMembers.delete(this.#ourAci);
if (otherMembers.size === 0) {
log.info(`${logId}: using combined endorsement`);
return this.#getCombinedEndorsement(includesOurs);
}
if (otherMembers.size < memberCount / 2) {
log.info(`${logId}: subtracting missing members`);
return this.#subtractMemberEndorsements(otherMembers, includesOurs);
}
log.info(`${logId}: combining all members`);
return this.#combineMemberEndorsements(serviceIds);
}
buildToken(serviceIds: Set<ServiceIdString>): GroupSendToken | null {
try {
return this.#toToken(this.#buildToken(new Set(serviceIds)));
} catch (error) {
onFailedToSendWithEndorsements(error);
}
return null;
}
}
export function onFailedToSendWithEndorsements(error: Error): void {
@ -278,3 +312,58 @@ export function onFailedToSendWithEndorsements(error: Error): void {
}
devDebugger();
}
type MaybeCreateGroupSendEndorsementStateResult =
| { state: GroupSendEndorsementState; didRefreshGroupState: false }
| { state: null; didRefreshGroupState: boolean };
export async function maybeCreateGroupSendEndorsementState(
groupId: string,
alreadyRefreshedGroupState: boolean
): Promise<MaybeCreateGroupSendEndorsementStateResult> {
const conversation = window.ConversationController.get(groupId);
strictAssert(
conversation != null,
'maybeCreateGroupSendEndorsementState: Convertion not found'
);
const logId = `maybeCreateGroupSendEndorsementState/${conversation.idForLogging()}`;
strictAssert(
isGroupV2(conversation.attributes),
`${logId}: Conversation is not groupV2`
);
const data = await DataReader.getGroupSendEndorsementsData(groupId);
if (data == null) {
const ourAci = window.textsecure.storage.user.getCheckedAci();
if (conversation.isMember(ourAci)) {
onFailedToSendWithEndorsements(
new Error(`${logId}: Missing all endorsements for group`)
);
}
return { state: null, didRefreshGroupState: false };
}
const groupSecretParamsBase64 = conversation.get('secretParams');
strictAssert(groupSecretParamsBase64, `${logId}: Must have secret params`);
const groupSendEndorsementState = new GroupSendEndorsementState(
data,
groupSecretParamsBase64
);
if (
groupSendEndorsementState != null &&
!groupSendEndorsementState.isSafeExpirationRange() &&
!alreadyRefreshedGroupState
) {
log.info(
`${logId}: Endorsements close to expiration (${groupSendEndorsementState.getExpiration().getTime()}, ${Date.now()}), refreshing group`
);
await maybeUpdateGroup({ conversation });
return { state: null, didRefreshGroupState: true };
}
return { state: groupSendEndorsementState, didRefreshGroupState: false };
}

85
ts/util/mapEmplace.ts Normal file
View file

@ -0,0 +1,85 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { RequireAtLeastOne } from 'type-fest';
export type MapKey<T extends Map<unknown, unknown>> =
T extends Map<infer Key, unknown> ? Key : never;
export type MapValue<T extends Map<unknown, unknown>> =
T extends Map<unknown, infer Value> ? Value : never;
export type MapEmplaceOptions<T extends Map<unknown, unknown>> =
RequireAtLeastOne<{
insert?: (key: MapKey<T>, map: T) => MapValue<T>;
update?: (existing: MapValue<T>, key: MapKey<T>, map: T) => MapValue<T>;
}>;
/**
* Lightweight polyfill of the `Map.prototype.emplace` TC39 Proposal:
* @see https://github.com/tc39/proposal-upsert
*
* @throws If no `insert()` is provided and key is not present
*
* @example Get or Insert:
* ```ts
* let pagesByBook = new Map<BookId, Map<PageId, Page>>()
* for (let page of pages) {
* let bookPages = mapEmplace(pagesByBook, page.bookId, {
* insert: () => new Map(),
* })
* bookPages.set(page.id, page)
* }
* ```
*
* @example Get+Update or Insert:
* ```ts
* let unreadPages = new Map<BookId, number>()
* for (let page of pages) {
* if (page.readAt == null) {
* mapEmplace(unreadPages, page.bookId, {
* insert: () => 1,
* update: (prev) => prev + 1,
* })
* }
* }
* ```
*
* @example Get+Update or Throw
* ```ts
* let PagesCache = new Map<PageId, Page>()
*
* function onPageReadEvent(pageId: PageId, readAt: number) {
* if (PagesCache.has(pageId)) {
* mapEmplace(PagesCache, pageId, {
* update(page) {
* return { ...page, readAt }
* },
* })
* } else {
* // save for later
* onEarlyPageReadEvent(pageId, readAt)
* }
* }
* ```
*/
export function mapEmplace<T extends Map<unknown, unknown>>(
map: T,
key: MapKey<T>,
options: MapEmplaceOptions<T>
): MapValue<T> {
if (map.has(key)) {
let value = map.get(key) as MapValue<T>;
if (options.update != null) {
value = options.update(value, key, map);
map.set(key, value);
}
return value;
}
if (options.insert != null) {
const value = options.insert(key, map);
map.set(key, value);
return value;
}
throw new Error('Key was not present in map, and insert() was not provided');
}

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { chunk } from 'lodash';
import { chunk, map } from 'lodash';
import type { LoggerType } from '../types/Logging';
import type { Receipt } from '../types/Receipt';
import { ReceiptType } from '../types/Receipt';
@ -9,8 +9,9 @@ import { getSendOptions } from './getSendOptions';
import { handleMessageSend } from './handleMessageSend';
import { isConversationAccepted } from './isConversationAccepted';
import { isConversationUnregistered } from './isConversationUnregistered';
import { map } from './iterables';
import { missingCaseError } from './missingCaseError';
import type { ConversationModel } from '../models/conversations';
import { mapEmplace } from './mapEmplace';
const CHUNK_SIZE = 100;
@ -57,47 +58,53 @@ export async function sendReceipts({
log.info(`Starting receipt send of type ${type}`);
const receiptsBySenderId: Map<string, Array<Receipt>> = receipts.reduce(
(result, receipt) => {
const { senderE164, senderAci } = receipt;
if (!senderE164 && !senderAci) {
log.error('no sender E164 or Service Id. Skipping this receipt');
return result;
}
type ConversationSenderReceiptGroup = {
conversationId: string;
sender: ConversationModel;
receipts: Array<Receipt>;
};
const groupsByConversation = new Map<
string,
Map<string, ConversationSenderReceiptGroup>
>();
const sender = window.ConversationController.lookupOrCreate({
e164: senderE164,
serviceId: senderAci,
reason: 'sendReceipts',
});
if (!sender) {
throw new Error(
'no conversation found with that E164/Service Id. Cannot send this receipt'
);
}
const allGroups = new Set<ConversationSenderReceiptGroup>();
const existingGroup = result.get(sender.id);
if (existingGroup) {
existingGroup.push(receipt);
} else {
result.set(sender.id, [receipt]);
}
for (const receipt of receipts) {
const { senderE164, senderAci, conversationId } = receipt;
if (!senderE164 && !senderAci) {
log.error('no sender E164 or Service Id. Skipping this receipt');
continue;
}
return result;
},
new Map()
);
const sender = window.ConversationController.lookupOrCreate({
e164: senderE164,
serviceId: senderAci,
reason: 'sendReceipts',
});
if (!sender) {
throw new Error(
'no conversation found with that E164/Service Id. Cannot send this receipt'
);
}
const groupsBySender = mapEmplace(groupsByConversation, conversationId, {
insert: () => new Map(),
});
const group = mapEmplace(groupsBySender, sender.id, {
insert: () => ({ conversationId, sender, receipts: [] }),
});
allGroups.add(group);
group.receipts.push(receipt);
}
await window.ConversationController.load();
await Promise.all(
map(receiptsBySenderId, async ([senderId, receiptsForSender]) => {
const sender = window.ConversationController.get(senderId);
if (!sender) {
throw new Error(
'despite having a conversation ID, no conversation was found'
);
}
Array.from(allGroups.values(), async group => {
const { conversationId, sender, receipts: receiptsForSender } = group;
if (!isConversationAccepted(sender.attributes)) {
log.info(
@ -120,7 +127,12 @@ export async function sendReceipts({
log.info(`Sending receipt of type ${type} to ${sender.idForLogging()}`);
const sendOptions = await getSendOptions(sender.attributes);
const conversation = window.ConversationController.get(conversationId);
const groupId = conversation?.get('groupId');
const sendOptions = await getSendOptions(sender.attributes, {
groupId,
});
const batches = chunk(receiptsForSender, CHUNK_SIZE);
await Promise.all(

View file

@ -23,7 +23,7 @@ import {
import { Address } from '../types/Address';
import { QualifiedAddress } from '../types/QualifiedAddress';
import * as Errors from '../types/errors';
import { DataWriter, DataReader } from '../sql/Client';
import { DataWriter } from '../sql/Client';
import { getValue } from '../RemoteConfig';
import type { ServiceIdString } from '../types/ServiceId';
import { ServiceIdKind } from '../types/ServiceId';
@ -66,11 +66,11 @@ import { strictAssert } from './assert';
import * as log from '../logging/log';
import { GLOBAL_ZONE } from '../SignalProtocolStore';
import { waitForAll } from './waitForAll';
import type { GroupSendEndorsementState } from './groupSendEndorsements';
import {
GroupSendEndorsementState,
maybeCreateGroupSendEndorsementState,
onFailedToSendWithEndorsements,
} from './groupSendEndorsements';
import { maybeUpdateGroup } from '../groups';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
import { isAciString } from './isAciString';
import { safeParseStrict, safeParseUnknown } from './schemas';
@ -213,11 +213,11 @@ export async function sendContentMessageToGroup(
if (sendTarget.isValid()) {
try {
return await sendToGroupViaSenderKey(
options,
0,
'init (sendContentMessageToGroup)'
);
return await sendToGroupViaSenderKey(options, {
count: 0,
didRefreshGroupState: false,
reason: 'init (sendContentMessageToGroup)',
});
} catch (error: unknown) {
if (!(error instanceof Error)) {
throw error;
@ -259,11 +259,27 @@ export async function sendContentMessageToGroup(
// The Primary Sender Key workflow
type SendRecursion = {
count: number;
didRefreshGroupState: boolean;
reason: string;
};
export async function sendToGroupViaSenderKey(
options: SendToGroupOptions,
recursionCount: number,
recursionReason: string
recursion: SendRecursion
): Promise<CallbackResultType> {
function startOver(
reason: string,
didRefreshGroupState = recursion.didRefreshGroupState
) {
return sendToGroupViaSenderKey(options, {
count: recursion.count + 1,
didRefreshGroupState,
reason,
});
}
const {
contentHint,
contentMessage,
@ -282,12 +298,12 @@ export async function sendToGroupViaSenderKey(
const logId = sendTarget.idForLogging();
log.info(
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}, reason: ${recursionReason}...`
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursion.count}, reason: ${recursion.reason}...`
);
if (recursionCount > MAX_RECURSION) {
if (recursion.count > MAX_RECURSION) {
throw new Error(
`sendToGroupViaSenderKey/${logId}: Too much recursion! Count is at ${recursionCount}`
`sendToGroupViaSenderKey/${logId}: Too much recursion! Count is at ${recursion.count}`
);
}
@ -328,11 +344,7 @@ export async function sendToGroupViaSenderKey(
});
// Restart here because we updated senderKeyInfo
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'Added missing sender key info'
);
return startOver('Added missing sender key info');
}
const EXPIRE_DURATION = getSenderKeyExpireDuration();
@ -344,11 +356,7 @@ export async function sendToGroupViaSenderKey(
await resetSenderKey(sendTarget);
// Restart here because we updated senderKeyInfo
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'sender key info expired'
);
return startOver('sender key info expired');
}
// 2. Fetch all devices we believe we'll be sending to
@ -356,49 +364,20 @@ export async function sendToGroupViaSenderKey(
const { devices: currentDevices, emptyServiceIds } =
await window.textsecure.storage.protocol.getOpenDevices(ourAci, recipients);
const conversation =
groupId != null
? (window.ConversationController.get(groupId) ?? null)
: null;
let groupSendEndorsementState: GroupSendEndorsementState | null = null;
if (groupId != null) {
strictAssert(conversation, 'Must have conversation for endorsements');
const data = await DataReader.getGroupSendEndorsementsData(groupId);
if (data == null) {
if (conversation.isMember(ourAci)) {
onFailedToSendWithEndorsements(
new Error(
`sendToGroupViaSenderKey/${logId}: Missing all endorsements for group`
)
);
}
} else {
log.info(
`sendToGroupViaSenderKey/${logId}: Loaded endorsements for ${data.memberEndorsements.length} members`
if (groupId != null && !story) {
const { state, didRefreshGroupState } =
await maybeCreateGroupSendEndorsementState(
groupId,
recursion.didRefreshGroupState
);
const groupSecretParamsBase64 = conversation.get('secretParams');
strictAssert(groupSecretParamsBase64, 'Must have secret params');
groupSendEndorsementState = new GroupSendEndorsementState(
data,
groupSecretParamsBase64
if (state != null) {
groupSendEndorsementState = state;
} else if (didRefreshGroupState) {
return startOver(
'group send endorsements outside expiration range',
true
);
if (
groupSendEndorsementState != null &&
!groupSendEndorsementState.isSafeExpirationRange()
) {
log.info(
`sendToGroupViaSenderKey/${logId}: Endorsements close to expiration (${groupSendEndorsementState.getExpiration().getTime()}, ${Date.now()}), refreshing group`
);
await maybeUpdateGroup({ conversation });
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'group send endorsements outside expiration range'
);
}
}
}
@ -412,11 +391,7 @@ export async function sendToGroupViaSenderKey(
await fetchKeysForServiceIds(emptyServiceIds, groupSendEndorsementState);
// Restart here to capture devices for accounts we just started sessions with
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'fetched prekey bundles'
);
return startOver('fetched prekey bundles');
}
const { memberDevices, distributionId, createdAtDate } = senderKeyInfo;
@ -478,11 +453,7 @@ export async function sendToGroupViaSenderKey(
// Restart here to start over; empty memberDevices means we'll send distribution
// message to everyone.
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'removed members in send target'
);
return startOver('removed members in send target');
}
// 8. If there are new members or new devices in the group, we need to ensure that they
@ -533,11 +504,7 @@ export async function sendToGroupViaSenderKey(
// Restart here because we might have discovered new or dropped devices as part of
// distributing our sender key.
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'sent skdm to new members'
);
return startOver('sent skdm to new members');
}
// 9. Update memberDevices with removals which didn't require a reset.
@ -572,17 +539,12 @@ export async function sendToGroupViaSenderKey(
senderKeyRecipientsWithDevices[serviceId].push(id);
});
let groupSendToken: GroupSendToken | undefined;
let accessKeys: Buffer | undefined;
let groupSendToken: GroupSendToken | null = null;
let accessKeys: Buffer | null = null;
if (groupSendEndorsementState != null) {
strictAssert(conversation, 'Must have conversation for endorsements');
try {
groupSendToken = groupSendEndorsementState.buildToken(
new Set(senderKeyRecipients)
);
} catch (error) {
onFailedToSendWithEndorsements(error);
}
groupSendToken = groupSendEndorsementState.buildToken(
new Set(senderKeyRecipients)
);
} else {
accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
}
@ -656,22 +618,14 @@ export async function sendToGroupViaSenderKey(
await handle409Response(sendTarget, groupSendEndorsementState, error);
// Restart here to capture the right set of devices for our next send.
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'error: expired or missing devices'
);
return startOver('error: expired or missing devices');
}
if (error.code === ERROR_STALE_DEVICES) {
await handle410Response(sendTarget, groupSendEndorsementState, error);
// Restart here to use the right registrationIds for devices we already knew about,
// as well as send our sender key to these re-registered or re-linked devices.
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'error: stale devices'
);
return startOver('error: stale devices');
}
if (
error instanceof LibSignalErrorBase &&
@ -689,11 +643,7 @@ export async function sendToGroupViaSenderKey(
await DataWriter.updateConversation(brokenAccount.attributes);
// Now that we've eliminate this problematic account, we can try the send again.
return sendToGroupViaSenderKey(
options,
recursionCount + 1,
'error: invalid registration id'
);
return startOver('error: invalid registration id');
}
}