503 lines
14 KiB
TypeScript
503 lines
14 KiB
TypeScript
// Copyright 2020-2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type {
|
|
ProfileKeyCredentialRequestContext,
|
|
ClientZkProfileOperations,
|
|
} from '@signalapp/libsignal-client/zkgroup';
|
|
import { SEALED_SENDER } from '../types/SealedSender';
|
|
import * as Errors from '../types/errors';
|
|
import type {
|
|
GetProfileOptionsType,
|
|
GetProfileUnauthOptionsType,
|
|
} from '../textsecure/WebAPI';
|
|
import { HTTPError } from '../textsecure/Errors';
|
|
import { Address } from '../types/Address';
|
|
import { QualifiedAddress } from '../types/QualifiedAddress';
|
|
import { UUIDKind } from '../types/UUID';
|
|
import * as Bytes from '../Bytes';
|
|
import { trimForDisplay, verifyAccessKey, decryptProfile } from '../Crypto';
|
|
import {
|
|
generateProfileKeyCredentialRequest,
|
|
generatePNICredentialRequest,
|
|
getClientZkProfileOperations,
|
|
handleProfileKeyCredential,
|
|
handleProfileKeyPNICredential,
|
|
} from './zkgroup';
|
|
import { isMe } from './whatTypeOfConversation';
|
|
import type { ConversationModel } from '../models/conversations';
|
|
import * as log from '../logging/log';
|
|
import { getUserLanguages } from './userLanguages';
|
|
import { parseBadgesFromServer } from '../badges/parseBadgesFromServer';
|
|
import { strictAssert } from './assert';
|
|
|
|
async function maybeGetPNICredential(
|
|
c: ConversationModel,
|
|
{
|
|
clientZkProfileCipher,
|
|
profileKey,
|
|
profileKeyVersion,
|
|
userLanguages,
|
|
}: {
|
|
clientZkProfileCipher: ClientZkProfileOperations;
|
|
profileKey: string;
|
|
profileKeyVersion: string;
|
|
userLanguages: ReadonlyArray<string>;
|
|
}
|
|
): Promise<void> {
|
|
// Already present and up-to-date
|
|
if (c.get('pniCredential')) {
|
|
return;
|
|
}
|
|
strictAssert(isMe(c.attributes), 'Has to fetch PNI credential for ourselves');
|
|
|
|
log.info('maybeGetPNICredential: requesting PNI credential');
|
|
|
|
const { storage, messaging } = window.textsecure;
|
|
strictAssert(
|
|
messaging,
|
|
'maybeGetPNICredential: window.textsecure.messaging not available'
|
|
);
|
|
|
|
const ourACI = storage.user.getCheckedUuid(UUIDKind.ACI);
|
|
const ourPNI = storage.user.getCheckedUuid(UUIDKind.PNI);
|
|
|
|
const {
|
|
requestHex: profileKeyCredentialRequestHex,
|
|
context: profileCredentialRequestContext,
|
|
} = generatePNICredentialRequest(
|
|
clientZkProfileCipher,
|
|
ourACI.toString(),
|
|
ourPNI.toString(),
|
|
profileKey
|
|
);
|
|
|
|
const profile = await messaging.getProfile(ourACI, {
|
|
userLanguages,
|
|
profileKeyVersion,
|
|
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
|
credentialType: 'pni',
|
|
});
|
|
|
|
strictAssert(
|
|
profile.pniCredential,
|
|
'We must get the credential for ourselves'
|
|
);
|
|
const pniCredential = handleProfileKeyPNICredential(
|
|
clientZkProfileCipher,
|
|
profileCredentialRequestContext,
|
|
profile.pniCredential
|
|
);
|
|
c.set({ pniCredential });
|
|
|
|
log.info('maybeGetPNICredential: updated PNI credential');
|
|
}
|
|
|
|
async function doGetProfile(c: ConversationModel): Promise<void> {
|
|
const idForLogging = c.idForLogging();
|
|
const { messaging } = window.textsecure;
|
|
strictAssert(
|
|
messaging,
|
|
'getProfile: window.textsecure.messaging not available'
|
|
);
|
|
|
|
const { updatesUrl } = window.SignalContext.config;
|
|
strictAssert(
|
|
typeof updatesUrl === 'string',
|
|
'getProfile: expected updatesUrl to be a defined string'
|
|
);
|
|
|
|
const clientZkProfileCipher = getClientZkProfileOperations(
|
|
window.getServerPublicParams()
|
|
);
|
|
|
|
const userLanguages = getUserLanguages(
|
|
navigator.languages,
|
|
window.getLocale()
|
|
);
|
|
|
|
let profile;
|
|
|
|
c.deriveAccessKeyIfNeeded();
|
|
|
|
const profileKey = c.get('profileKey');
|
|
const profileKeyVersion = c.deriveProfileKeyVersion();
|
|
const uuid = c.getCheckedUuid('getProfile');
|
|
const existingProfileKeyCredential = c.get('profileKeyCredential');
|
|
const lastProfile = c.get('lastProfile');
|
|
|
|
let profileCredentialRequestContext:
|
|
| undefined
|
|
| ProfileKeyCredentialRequestContext;
|
|
|
|
let getProfileOptions: GetProfileOptionsType | GetProfileUnauthOptionsType;
|
|
|
|
let accessKey = c.get('accessKey');
|
|
if (profileKey) {
|
|
strictAssert(
|
|
profileKeyVersion && accessKey,
|
|
'profileKeyVersion and accessKey are derived from profileKey'
|
|
);
|
|
|
|
if (existingProfileKeyCredential) {
|
|
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,
|
|
uuid.toString(),
|
|
profileKey
|
|
));
|
|
|
|
getProfileOptions = {
|
|
accessKey,
|
|
userLanguages,
|
|
profileKeyVersion,
|
|
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
|
};
|
|
}
|
|
} else {
|
|
strictAssert(
|
|
!accessKey,
|
|
'accessKey have to be absent because there is no profileKey'
|
|
);
|
|
|
|
if (lastProfile?.profileKeyVersion) {
|
|
getProfileOptions = {
|
|
userLanguages,
|
|
profileKeyVersion: lastProfile.profileKeyVersion,
|
|
};
|
|
} else {
|
|
getProfileOptions = { userLanguages };
|
|
}
|
|
}
|
|
|
|
const isVersioned = Boolean(getProfileOptions.profileKeyVersion);
|
|
log.info(
|
|
`getProfile: getting ${isVersioned ? 'versioned' : 'unversioned'} ` +
|
|
`profile for conversation ${idForLogging}`
|
|
);
|
|
|
|
try {
|
|
if (getProfileOptions.accessKey) {
|
|
try {
|
|
profile = await messaging.getProfile(uuid, getProfileOptions);
|
|
} catch (error) {
|
|
if (!(error instanceof HTTPError)) {
|
|
throw error;
|
|
}
|
|
if (error.code === 401 || error.code === 403) {
|
|
if (isMe(c.attributes)) {
|
|
throw error;
|
|
}
|
|
|
|
await c.setProfileKey(undefined);
|
|
|
|
// Retry fetch using last known profileKeyVersion or fetch
|
|
// unversioned profile.
|
|
return doGetProfile(c);
|
|
}
|
|
|
|
if (error.code === 404) {
|
|
await c.removeLastProfile(lastProfile);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
} 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(uuid, getProfileOptions);
|
|
} catch (error) {
|
|
if (error instanceof HTTPError && error.code === 404) {
|
|
log.info(`getProfile: failed to find a profile for ${idForLogging}`);
|
|
|
|
await c.removeLastProfile(lastProfile);
|
|
if (!isVersioned) {
|
|
log.info(`getProfile: marking ${idForLogging} as unregistered`);
|
|
c.setUnregistered();
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (isMe(c.attributes) && profileKey && profileKeyVersion) {
|
|
try {
|
|
await maybeGetPNICredential(c, {
|
|
clientZkProfileCipher,
|
|
profileKey,
|
|
profileKeyVersion,
|
|
userLanguages,
|
|
});
|
|
} catch (error) {
|
|
log.warn(
|
|
'getProfile failed to get our own PNI credential',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (profile.identityKey) {
|
|
const identityKey = Bytes.fromBase64(profile.identityKey);
|
|
const changed = await window.textsecure.storage.protocol.saveIdentity(
|
|
new Address(uuid, 1),
|
|
identityKey,
|
|
false
|
|
);
|
|
if (changed) {
|
|
// save identity will close all sessions except for .1, so we
|
|
// must close that one manually.
|
|
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
|
|
await window.textsecure.storage.protocol.archiveSession(
|
|
new QualifiedAddress(ourUuid, new Address(uuid, 1))
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update accessKey to prevent race conditions. Since we run asynchronous
|
|
// requests above - it is possible that someone updates or erases
|
|
// the profile key from under us.
|
|
accessKey = c.get('accessKey');
|
|
|
|
if (profile.unrestrictedUnidentifiedAccess && profile.unidentifiedAccess) {
|
|
log.info(
|
|
`getProfile: setting sealedSender to UNRESTRICTED for conversation ${idForLogging}`
|
|
);
|
|
c.set({
|
|
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.paymentAddress && isMe(c.attributes)) {
|
|
window.storage.put('paymentAddress', profile.paymentAddress);
|
|
}
|
|
|
|
if (profile.capabilities) {
|
|
c.set({ capabilities: profile.capabilities });
|
|
} else {
|
|
c.unset('capabilities');
|
|
}
|
|
|
|
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 {
|
|
c.unset('profileKeyCredential');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof HTTPError)) {
|
|
throw error;
|
|
}
|
|
|
|
switch (error.code) {
|
|
case 401:
|
|
case 403:
|
|
if (
|
|
c.get('sealedSender') === SEALED_SENDER.ENABLED ||
|
|
c.get('sealedSender') === SEALED_SENDER.UNRESTRICTED
|
|
) {
|
|
log.warn(
|
|
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, removing profileKey`
|
|
);
|
|
if (!isMe(c.attributes)) {
|
|
await c.setProfileKey(undefined);
|
|
}
|
|
}
|
|
if (c.get('sealedSender') === SEALED_SENDER.UNKNOWN) {
|
|
log.warn(
|
|
`getProfile: Got 401/403 when using accessKey for ${idForLogging}, setting sealedSender = DISABLED`
|
|
);
|
|
c.set('sealedSender', SEALED_SENDER.DISABLED);
|
|
}
|
|
return;
|
|
default:
|
|
log.warn(
|
|
'getProfile failure:',
|
|
idForLogging,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const decryptionKeyString = profileKey || lastProfile?.profileKey;
|
|
const decryptionKey = decryptionKeyString
|
|
? Bytes.fromBase64(decryptionKeyString)
|
|
: undefined;
|
|
|
|
let isSuccessfullyDecrypted = true;
|
|
if (profile.name) {
|
|
if (decryptionKey) {
|
|
try {
|
|
await c.setEncryptedProfileName(profile.name, decryptionKey);
|
|
} catch (error) {
|
|
log.warn(
|
|
'getProfile decryption failure:',
|
|
idForLogging,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
isSuccessfullyDecrypted = false;
|
|
await c.set({
|
|
profileName: undefined,
|
|
profileFamilyName: undefined,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
c.set({
|
|
profileName: undefined,
|
|
profileFamilyName: undefined,
|
|
});
|
|
}
|
|
|
|
try {
|
|
if (decryptionKey) {
|
|
await c.setProfileAvatar(profile.avatar, decryptionKey);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof HTTPError) {
|
|
if (error.code === 403 || error.code === 404) {
|
|
log.warn(
|
|
`getProfile: profile avatar is missing for conversation ${idForLogging}`
|
|
);
|
|
}
|
|
} else {
|
|
log.warn(
|
|
`getProfile: failed to decrypt avatar for conversation ${idForLogging}`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
isSuccessfullyDecrypted = false;
|
|
}
|
|
}
|
|
|
|
c.set('profileLastFetchedAt', Date.now());
|
|
|
|
// After we successfully decrypted - update lastProfile property
|
|
if (
|
|
isSuccessfullyDecrypted &&
|
|
profileKey &&
|
|
getProfileOptions.profileKeyVersion
|
|
) {
|
|
await c.updateLastProfile(lastProfile, {
|
|
profileKey,
|
|
profileKeyVersion: getProfileOptions.profileKeyVersion,
|
|
});
|
|
}
|
|
|
|
window.Signal.Data.updateConversation(c.attributes);
|
|
}
|
|
|
|
export async function getProfile(
|
|
providedUuid?: string,
|
|
providedE164?: string
|
|
): Promise<void> {
|
|
const id = window.ConversationController.ensureContactIds({
|
|
uuid: providedUuid,
|
|
e164: providedE164,
|
|
});
|
|
const c = window.ConversationController.get(id);
|
|
if (!c) {
|
|
log.error('getProfile: failed to find conversation; doing nothing');
|
|
return;
|
|
}
|
|
|
|
await doGetProfile(c);
|
|
}
|