Improvements to shared library components

This commit is contained in:
Josh Perez 2021-07-21 16:45:41 -04:00 committed by GitHub
parent 2c59c71872
commit d9e90e9ea8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 485 additions and 348 deletions

View file

@ -38,6 +38,7 @@ import { ourProfileKeyService } from './services/ourProfileKey';
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
import { LatestQueue } from './util/LatestQueue'; import { LatestQueue } from './util/LatestQueue';
import { parseIntOrThrow } from './util/parseIntOrThrow'; import { parseIntOrThrow } from './util/parseIntOrThrow';
import { getProfile } from './util/getProfile';
import { import {
TypingEvent, TypingEvent,
ErrorEvent, ErrorEvent,
@ -3655,10 +3656,12 @@ export async function startApp(): Promise<void> {
const FETCH_LATEST_ENUM = Proto.SyncMessage.FetchLatest.Type; const FETCH_LATEST_ENUM = Proto.SyncMessage.FetchLatest.Type;
switch (eventType) { switch (eventType) {
case FETCH_LATEST_ENUM.LOCAL_PROFILE: case FETCH_LATEST_ENUM.LOCAL_PROFILE: {
// Intentionally do nothing since we'll be receiving the const ourUuid = window.textsecure.storage.user.getUuid();
// window.storage manifest request and will update local profile along with that. const ourE164 = window.textsecure.storage.user.getNumber();
await getProfile(ourUuid, ourE164);
break; break;
}
case FETCH_LATEST_ENUM.STORAGE_MANIFEST: case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
window.log.info('onFetchLatestSync: fetching latest manifest'); window.log.info('onFetchLatestSync: fetching latest manifest');
await window.Signal.Services.runStorageServiceSyncJob(); await window.Signal.Services.runStorageServiceSyncJob();

View file

@ -22,7 +22,7 @@ export const GroupDescriptionInput = forwardRef<HTMLInputElement, PropsType>(
i18n={i18n} i18n={i18n}
onChange={onChangeValue} onChange={onChangeValue}
placeholder={i18n('setGroupMetadata__group-description-placeholder')} placeholder={i18n('setGroupMetadata__group-description-placeholder')}
maxGraphemeCount={256} maxLengthCount={256}
ref={ref} ref={ref}
value={value} value={value}
whenToShowRemainingCount={150} whenToShowRemainingCount={150}

View file

@ -21,7 +21,7 @@ export const GroupTitleInput = forwardRef<HTMLInputElement, PropsType>(
i18n={i18n} i18n={i18n}
onChange={onChangeValue} onChange={onChangeValue}
placeholder={i18n('setGroupMetadata__group-name-placeholder')} placeholder={i18n('setGroupMetadata__group-name-placeholder')}
maxGraphemeCount={32} maxLengthCount={32}
ref={ref} ref={ref}
value={value} value={value}
/> />

View file

@ -21,7 +21,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
hasClearButton: Boolean(overrideProps.hasClearButton), hasClearButton: Boolean(overrideProps.hasClearButton),
i18n, i18n,
icon: overrideProps.icon, icon: overrideProps.icon,
maxGraphemeCount: overrideProps.maxGraphemeCount, maxLengthCount: overrideProps.maxLengthCount,
onChange: action('onChange'), onChange: action('onChange'),
placeholder: text( placeholder: text(
'placeholder', 'placeholder',
@ -51,7 +51,7 @@ stories.add('hasClearButton', () => (
stories.add('character count', () => ( stories.add('character count', () => (
<Controller <Controller
{...createProps({ {...createProps({
maxGraphemeCount: 10, maxLengthCount: 10,
})} })}
/> />
)); ));
@ -59,7 +59,7 @@ stories.add('character count', () => (
stories.add('character count (customizable show)', () => ( stories.add('character count (customizable show)', () => (
<Controller <Controller
{...createProps({ {...createProps({
maxGraphemeCount: 64, maxLengthCount: 64,
whenToShowRemainingCount: 32, whenToShowRemainingCount: 32,
})} })}
/> />
@ -78,7 +78,7 @@ stories.add('expandable w/count', () => (
{...createProps({ {...createProps({
expandable: true, expandable: true,
hasClearButton: true, hasClearButton: true,
maxGraphemeCount: 140, maxLengthCount: 140,
whenToShowRemainingCount: 0, whenToShowRemainingCount: 0,
})} })}
/> />

View file

@ -15,12 +15,13 @@ import { getClassNamesFor } from '../util/getClassNamesFor';
import { multiRef } from '../util/multiRef'; import { multiRef } from '../util/multiRef';
export type PropsType = { export type PropsType = {
countLength?: (value: string) => number;
disabled?: boolean; disabled?: boolean;
expandable?: boolean; expandable?: boolean;
hasClearButton?: boolean; hasClearButton?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
icon?: ReactNode; icon?: ReactNode;
maxGraphemeCount?: number; maxLengthCount?: number;
moduleClassName?: string; moduleClassName?: string;
onChange: (value: string) => unknown; onChange: (value: string) => unknown;
placeholder: string; placeholder: string;
@ -29,7 +30,7 @@ export type PropsType = {
}; };
/** /**
* Some inputs must have fewer than maxGraphemeCount glyphs. Ideally, we'd use the * Some inputs must have fewer than maxLengthCount glyphs. Ideally, we'd use the
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than * `maxLength` property on inputs, but that doesn't account for glyphs that are more than
* one UTF-16 code units. For example: `'💩💩'.length === 4`. * one UTF-16 code units. For example: `'💩💩'.length === 4`.
* *
@ -51,12 +52,13 @@ export const Input = forwardRef<
>( >(
( (
{ {
countLength = grapheme.count,
disabled, disabled,
expandable, expandable,
hasClearButton, hasClearButton,
i18n, i18n,
icon, icon,
maxGraphemeCount = 0, maxLengthCount = 0,
moduleClassName, moduleClassName,
onChange, onChange,
placeholder, placeholder,
@ -108,9 +110,9 @@ export const Input = forwardRef<
const newValue = inputEl.value; const newValue = inputEl.value;
const newGraphemeCount = maxGraphemeCount ? grapheme.count(newValue) : 0; const newLengthCount = maxLengthCount ? countLength(newValue) : 0;
if (newGraphemeCount <= maxGraphemeCount) { if (newLengthCount <= maxLengthCount) {
onChange(newValue); onChange(newValue);
} else { } else {
inputEl.value = valueOnKeydownRef.current; inputEl.value = valueOnKeydownRef.current;
@ -119,12 +121,12 @@ export const Input = forwardRef<
} }
maybeSetLarge(); maybeSetLarge();
}, [maxGraphemeCount, maybeSetLarge, onChange]); }, [countLength, maxLengthCount, maybeSetLarge, onChange]);
const handlePaste = useCallback( const handlePaste = useCallback(
(event: ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => { (event: ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const inputEl = innerRef.current; const inputEl = innerRef.current;
if (!inputEl || !maxGraphemeCount) { if (!inputEl || !maxLengthCount) {
return; return;
} }
@ -136,25 +138,25 @@ export const Input = forwardRef<
const pastedText = event.clipboardData.getData('Text'); const pastedText = event.clipboardData.getData('Text');
const newGraphemeCount = const newLengthCount =
grapheme.count(textBeforeSelection) + countLength(textBeforeSelection) +
grapheme.count(pastedText) + countLength(pastedText) +
grapheme.count(textAfterSelection); countLength(textAfterSelection);
if (newGraphemeCount > maxGraphemeCount) { if (newLengthCount > maxLengthCount) {
event.preventDefault(); event.preventDefault();
} }
maybeSetLarge(); maybeSetLarge();
}, },
[maxGraphemeCount, maybeSetLarge, value] [countLength, maxLengthCount, maybeSetLarge, value]
); );
useEffect(() => { useEffect(() => {
maybeSetLarge(); maybeSetLarge();
}, [maybeSetLarge]); }, [maybeSetLarge]);
const graphemeCount = maxGraphemeCount ? grapheme.count(value) : -1; const lengthCount = maxLengthCount ? countLength(value) : -1;
const getClassName = getClassNamesFor('Input', moduleClassName); const getClassName = getClassNamesFor('Input', moduleClassName);
const inputProps = { const inputProps = {
@ -187,9 +189,9 @@ export const Input = forwardRef<
/> />
) : null; ) : null;
const graphemeCountElement = graphemeCount >= whenToShowRemainingCount && ( const lengthCountElement = lengthCount >= whenToShowRemainingCount && (
<div className={getClassName('__remaining-count')}> <div className={getClassName('__remaining-count')}>
{maxGraphemeCount - graphemeCount} {maxLengthCount - lengthCount}
</div> </div>
); );
@ -208,12 +210,12 @@ export const Input = forwardRef<
{clearButtonElement} {clearButtonElement}
</div> </div>
<div className={getClassName('__remaining-count--large')}> <div className={getClassName('__remaining-count--large')}>
{graphemeCountElement} {lengthCountElement}
</div> </div>
</> </>
) : ( ) : (
<div className={getClassName('__controls')}> <div className={getClassName('__controls')}>
{graphemeCountElement} {lengthCountElement}
{clearButtonElement} {clearButtonElement}
</div> </div>
)} )}

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import * as grapheme from '../util/grapheme';
import { AvatarInputContainer } from './AvatarInputContainer'; import { AvatarInputContainer } from './AvatarInputContainer';
import { AvatarInputType } from './AvatarInput'; import { AvatarInputType } from './AvatarInput';
@ -141,9 +140,19 @@ export const ProfileEditor = ({
[setAvatarData] [setAvatarData]
); );
const calculateGraphemeCount = useCallback((name = '') => { const getTextEncoder = useCallback(() => new TextEncoder(), []);
return 256 - grapheme.count(name);
}, []); const countByteLength = useCallback(
(str: string) => getTextEncoder().encode(str).byteLength,
[getTextEncoder]
);
const calculateLengthCount = useCallback(
(name = '') => {
return 256 - countByteLength(name);
},
[countByteLength]
);
useEffect(() => { useEffect(() => {
const focusNode = focusInputRef.current; const focusNode = focusInputRef.current;
@ -155,11 +164,18 @@ export const ProfileEditor = ({
}, [editState]); }, [editState]);
if (editState === EditState.ProfileName) { if (editState === EditState.ProfileName) {
const shouldDisableSave =
!stagedProfile.firstName ||
(stagedProfile.firstName === fullName.firstName &&
stagedProfile.familyName === fullName.familyName);
content = ( content = (
<> <>
<Input <Input
countLength={countByteLength}
i18n={i18n} i18n={i18n}
maxGraphemeCount={calculateGraphemeCount(stagedProfile.familyName)} maxLengthCount={calculateLengthCount(stagedProfile.familyName)}
whenToShowRemainingCount={0}
onChange={newFirstName => { onChange={newFirstName => {
setStagedProfile(profileData => ({ setStagedProfile(profileData => ({
...profileData, ...profileData,
@ -171,8 +187,10 @@ export const ProfileEditor = ({
value={stagedProfile.firstName} value={stagedProfile.firstName}
/> />
<Input <Input
countLength={countByteLength}
i18n={i18n} i18n={i18n}
maxGraphemeCount={calculateGraphemeCount(stagedProfile.firstName)} maxLengthCount={calculateLengthCount(stagedProfile.firstName)}
whenToShowRemainingCount={0}
onChange={newFamilyName => { onChange={newFamilyName => {
setStagedProfile(profileData => ({ setStagedProfile(profileData => ({
...profileData, ...profileData,
@ -208,14 +226,14 @@ export const ProfileEditor = ({
{i18n('cancel')} {i18n('cancel')}
</Button> </Button>
<Button <Button
disabled={!stagedProfile.firstName} disabled={shouldDisableSave}
onClick={() => { onClick={() => {
if (!stagedProfile.firstName) { if (!stagedProfile.firstName) {
return; return;
} }
setFullName({ setFullName({
firstName, firstName: stagedProfile.firstName,
familyName, familyName: stagedProfile.familyName,
}); });
onProfileChanged(stagedProfile, avatarData); onProfileChanged(stagedProfile, avatarData);
@ -228,6 +246,10 @@ export const ProfileEditor = ({
</> </>
); );
} else if (editState === EditState.Bio) { } else if (editState === EditState.Bio) {
const shouldDisableSave =
stagedProfile.aboutText === fullBio.aboutText &&
stagedProfile.aboutEmoji === fullBio.aboutEmoji;
content = ( content = (
<> <>
<Input <Input
@ -247,7 +269,7 @@ export const ProfileEditor = ({
/> />
</div> </div>
} }
maxGraphemeCount={140} maxLengthCount={140}
moduleClassName="ProfileEditor__about-input" moduleClassName="ProfileEditor__about-input"
onChange={value => { onChange={value => {
if (value) { if (value) {
@ -317,6 +339,7 @@ export const ProfileEditor = ({
{i18n('cancel')} {i18n('cancel')}
</Button> </Button>
<Button <Button
disabled={shouldDisableSave}
onClick={() => { onClick={() => {
setFullBio({ setFullBio({
aboutEmoji: stagedProfile.aboutEmoji, aboutEmoji: stagedProfile.aboutEmoji,

View file

@ -4,6 +4,7 @@
import React, { import React, {
FormEventHandler, FormEventHandler,
FunctionComponent, FunctionComponent,
useCallback,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@ -111,6 +112,13 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
makeRequest(request); makeRequest(request);
}; };
const handleAvatarLoaded = useCallback(
loadedAvatar => {
setAvatar(loadedAvatar);
},
[setAvatar]
);
return ( return (
<Modal <Modal
hasXButton hasXButton
@ -131,9 +139,7 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
setAvatar(newAvatar); setAvatar(newAvatar);
setHasAvatarChanged(true); setHasAvatarChanged(true);
}} }}
onAvatarLoaded={loadedAvatar => { onAvatarLoaded={handleAvatarLoaded}
setAvatar(loadedAvatar);
}}
variant={AvatarInputVariant.Dark} variant={AvatarInputVariant.Dark}
/> />

View file

@ -3,7 +3,6 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
import { compact, sample } from 'lodash'; import { compact, sample } from 'lodash';
import { import {
ConversationAttributesType, ConversationAttributesType,
@ -39,8 +38,6 @@ import {
deriveAccessKey, deriveAccessKey,
fromEncodedBinaryToArrayBuffer, fromEncodedBinaryToArrayBuffer,
stringFromBytes, stringFromBytes,
trimForDisplay,
verifyAccessKey,
} from '../Crypto'; } from '../Crypto';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { BodyRangesType } from '../types/Util'; import { BodyRangesType } from '../types/Util';
@ -82,6 +79,8 @@ import {
import { Deletes } from '../messageModifiers/Deletes'; import { Deletes } from '../messageModifiers/Deletes';
import { Reactions, ReactionModel } from '../messageModifiers/Reactions'; import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
import { getProfile } from '../util/getProfile';
import { SEALED_SENDER } from '../types/SealedSender';
// TODO: remove once we move away from ArrayBuffers // TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array; const FIXMEU8 = Uint8Array;
@ -89,13 +88,6 @@ const FIXMEU8 = Uint8Array;
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
const SEALED_SENDER = {
UNKNOWN: 0,
ENABLED: 1,
DISABLED: 2,
UNRESTRICTED: 3,
};
const { Services, Util } = window.Signal; const { Services, Util } = window.Signal;
const { Contact, Message } = window.Signal.Types; const { Contact, Message } = window.Signal.Types;
const { const {
@ -4388,273 +4380,11 @@ export class ConversationModel extends window.Backbone
const conversations = (this.getMembers() as unknown) as Array<ConversationModel>; const conversations = (this.getMembers() as unknown) as Array<ConversationModel>;
return Promise.all( return Promise.all(
window._.map(conversations, conversation => { window._.map(conversations, conversation => {
this.getProfile(conversation.get('uuid'), conversation.get('e164')); getProfile(conversation.get('uuid'), conversation.get('e164'));
}) })
); );
} }
async getProfile(
providedUuid?: string,
providedE164?: string
): Promise<void> {
if (!window.textsecure.messaging) {
throw new Error(
'Conversation.getProfile: window.textsecure.messaging not available'
);
}
const id = window.ConversationController.ensureContactIds({
uuid: providedUuid,
e164: providedE164,
});
const c = window.ConversationController.get(id);
if (!c) {
window.log.error(
'getProfile: failed to find conversation; doing nothing'
);
return;
}
const {
generateProfileKeyCredentialRequest,
getClientZkProfileOperations,
handleProfileKeyCredential,
} = Util.zkgroup;
const clientZkProfileCipher = getClientZkProfileOperations(
window.getServerPublicParams()
);
let profile;
try {
await Promise.all([
c.deriveAccessKeyIfNeeded(),
c.deriveProfileKeyVersionIfNeeded(),
]);
const profileKey = c.get('profileKey');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const uuid = c.get('uuid')!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identifier = c.getSendTarget()!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const profileKeyVersionHex = c.get('profileKeyVersion')!;
const existingProfileKeyCredential = c.get('profileKeyCredential');
let profileKeyCredentialRequestHex: undefined | string;
let profileCredentialRequestContext:
| undefined
| ProfileKeyCredentialRequestContext;
if (
profileKey &&
uuid &&
profileKeyVersionHex &&
!existingProfileKeyCredential
) {
window.log.info('Generating request...');
({
requestHex: profileKeyCredentialRequestHex,
context: profileCredentialRequestContext,
} = generateProfileKeyCredentialRequest(
clientZkProfileCipher,
uuid,
profileKey
));
}
const { sendMetadata = {} } = await getSendOptions(c.attributes);
const getInfo =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};
if (getInfo.accessKey) {
try {
profile = await window.textsecure.messaging.getProfile(identifier, {
accessKey: getInfo.accessKey,
profileKeyVersion: profileKeyVersionHex,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
});
} catch (error) {
if (error.code === 401 || error.code === 403) {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
);
c.set({ sealedSender: SEALED_SENDER.DISABLED });
profile = await window.textsecure.messaging.getProfile(identifier, {
profileKeyVersion: profileKeyVersionHex,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
});
} else {
throw error;
}
}
} else {
profile = await window.textsecure.messaging.getProfile(identifier, {
profileKeyVersion: profileKeyVersionHex,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
});
}
const identityKey = base64ToArrayBuffer(profile.identityKey);
const changed = await window.textsecure.storage.protocol.saveIdentity(
`${identifier}.1`,
identityKey,
false
);
if (changed) {
// save identity will close all sessions except for .1, so we
// must close that one manually.
await window.textsecure.storage.protocol.archiveSession(
`${identifier}.1`
);
}
const accessKey = c.get('accessKey');
if (
profile.unrestrictedUnidentifiedAccess &&
profile.unidentifiedAccess
) {
window.log.info(
`Setting sealedSender to UNRESTRICTED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.UNRESTRICTED,
});
} else if (accessKey && profile.unidentifiedAccess) {
const haveCorrectKey = await verifyAccessKey(
base64ToArrayBuffer(accessKey),
base64ToArrayBuffer(profile.unidentifiedAccess)
);
if (haveCorrectKey) {
window.log.info(
`Setting sealedSender to ENABLED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.ENABLED,
});
} else {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.DISABLED,
});
}
} else {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.DISABLED,
});
}
if (profile.about) {
const key = c.get('profileKey');
if (key) {
const keyBuffer = base64ToArrayBuffer(key);
const decrypted = await window.textsecure.crypto.decryptProfile(
base64ToArrayBuffer(profile.about),
keyBuffer
);
c.set('about', stringFromBytes(trimForDisplay(decrypted)));
}
} else {
c.unset('about');
}
if (profile.aboutEmoji) {
const key = c.get('profileKey');
if (key) {
const keyBuffer = base64ToArrayBuffer(key);
const decrypted = await window.textsecure.crypto.decryptProfile(
base64ToArrayBuffer(profile.aboutEmoji),
keyBuffer
);
c.set('aboutEmoji', stringFromBytes(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');
}
if (profileCredentialRequestContext) {
if (profile.credential) {
const profileKeyCredential = handleProfileKeyCredential(
clientZkProfileCipher,
profileCredentialRequestContext,
profile.credential
);
c.set({ profileKeyCredential });
} else {
c.unset('profileKeyCredential');
}
}
} catch (error) {
switch (error?.code) {
case 403:
throw error;
case 404:
window.log.warn(
`getProfile failure: failed to find a profile for ${c.idForLogging()}`,
error && error.stack ? error.stack : error
);
c.setUnregistered();
return;
default:
window.log.warn(
'getProfile failure:',
c.idForLogging(),
error && error.stack ? error.stack : error
);
return;
}
}
try {
await c.setEncryptedProfileName(profile.name);
} catch (error) {
window.log.warn(
'getProfile decryption failure:',
c.idForLogging(),
error && error.stack ? error.stack : error
);
await c.set({
profileName: undefined,
profileFamilyName: undefined,
});
}
try {
await c.setProfileAvatar(profile.avatar);
} catch (error) {
if (error.code === 403 || error.code === 404) {
window.log.info(
`Clearing profile avatar for conversation ${c.idForLogging()}`
);
c.set({
profileAvatar: null,
});
}
}
c.set('profileLastFetchedAt', Date.now());
window.Signal.Data.updateConversation(c.attributes);
}
async setEncryptedProfileName(encryptedName: string): Promise<void> { async setEncryptedProfileName(encryptedName: string): Promise<void> {
if (!encryptedName) { if (!encryptedName) {
return; return;

View file

@ -15,6 +15,8 @@ import { take } from './util/iterables';
import { isOlderThan } from './util/timestamp'; import { isOlderThan } from './util/timestamp';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
import { StorageInterface } from './types/Storage.d'; import { StorageInterface } from './types/Storage.d';
// Imported this way so that sinon.sandbox can stub this properly
import * as profileGetter from './util/getProfile';
const STORAGE_KEY = 'lastAttemptedToRefreshProfilesAt'; const STORAGE_KEY = 'lastAttemptedToRefreshProfilesAt';
const MAX_AGE_TO_BE_CONSIDERED_ACTIVE = 30 * 24 * 60 * 60 * 1000; const MAX_AGE_TO_BE_CONSIDERED_ACTIVE = 30 * 24 * 60 * 60 * 1000;
@ -60,7 +62,7 @@ export async function routineProfileRefresh({
totalCount += 1; totalCount += 1;
try { try {
await conversation.getProfile( await profileGetter.getProfile(
conversation.get('uuid'), conversation.get('uuid'),
conversation.get('e164') conversation.get('e164')
); );

View file

@ -1,10 +1,12 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { computeHash } from '../Crypto';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import { ConversationType } from '../state/ducks/conversations'; import { ConversationType } from '../state/ducks/conversations';
import { computeHash } from '../Crypto';
import { encryptProfileData } from '../util/encryptProfileData'; import { encryptProfileData } from '../util/encryptProfileData';
import { getProfile } from '../util/getProfile';
import { handleMessageSend } from '../util/handleMessageSend';
export async function writeProfile( export async function writeProfile(
conversation: ConversationType, conversation: ConversationType,
@ -16,7 +18,7 @@ export async function writeProfile(
if (!model) { if (!model) {
return; return;
} }
await model.getProfile(model.get('uuid'), model.get('e164')); await getProfile(model.get('uuid'), model.get('e164'));
// Encrypt the profile data, update profile, and if needed upload the avatar // Encrypt the profile data, update profile, and if needed upload the avatar
const { const {
@ -64,25 +66,27 @@ export async function writeProfile(
hash, hash,
path, path,
}; };
} else {
profileAvatar = {
hash: String(avatarHash),
path: String(avatarPath),
};
} }
} else if (avatarPath) { } else if (avatarPath) {
await window.Signal.Migrations.deleteAttachmentData(avatarPath); await window.Signal.Migrations.deleteAttachmentData(avatarPath);
} }
const profileAvatarData = profileAvatar ? { profileAvatar } : {};
// Update backbone, update DB, run storage service upload // Update backbone, update DB, run storage service upload
model.set({ model.set({
about: aboutText, about: aboutText,
aboutEmoji, aboutEmoji,
profileAvatar,
profileName: firstName, profileName: firstName,
profileFamilyName: familyName, profileFamilyName: familyName,
...profileAvatarData,
}); });
dataInterface.updateConversation(model.attributes); dataInterface.updateConversation(model.attributes);
model.captureChange('writeProfile'); model.captureChange('writeProfile');
await handleMessageSend(
window.textsecure.messaging.sendFetchLocalProfileSyncMessage(),
{ messageIds: [], sendType: 'otherSync' }
);
} }

View file

@ -8,12 +8,14 @@ import { ConversationModel } from '../models/conversations';
import { ConversationAttributesType } from '../model-types.d'; import { ConversationAttributesType } from '../model-types.d';
import { routineProfileRefresh } from '../routineProfileRefresh'; import { routineProfileRefresh } from '../routineProfileRefresh';
import * as getProfileStub from '../util/getProfile';
describe('routineProfileRefresh', () => { describe('routineProfileRefresh', () => {
let sinonSandbox: sinon.SinonSandbox; let sinonSandbox: sinon.SinonSandbox;
beforeEach(() => { beforeEach(() => {
sinonSandbox = sinon.createSandbox(); sinonSandbox = sinon.createSandbox();
sinonSandbox.stub(getProfileStub, 'getProfile').resolves(undefined);
}); });
afterEach(() => { afterEach(() => {
@ -24,16 +26,17 @@ describe('routineProfileRefresh', () => {
overrideAttributes: Partial<ConversationAttributesType> = {} overrideAttributes: Partial<ConversationAttributesType> = {}
): ConversationModel { ): ConversationModel {
const result = new ConversationModel({ const result = new ConversationModel({
profileSharing: true,
left: false,
accessKey: uuid(), accessKey: uuid(),
active_at: Date.now(),
draftAttachments: [], draftAttachments: [],
draftBodyRanges: [], draftBodyRanges: [],
draftTimestamp: null, draftTimestamp: null,
id: uuid(),
inbox_position: 0, inbox_position: 0,
isPinned: false, isPinned: false,
lastMessageDeletedForEveryone: false, lastMessageDeletedForEveryone: false,
lastMessageStatus: 'sent', lastMessageStatus: 'sent',
left: false,
markedUnread: false, markedUnread: false,
messageCount: 2, messageCount: 2,
messageCountBeforeMessageRequests: 0, messageCountBeforeMessageRequests: 0,
@ -42,18 +45,17 @@ describe('routineProfileRefresh', () => {
profileAvatar: undefined, profileAvatar: undefined,
profileKeyCredential: uuid(), profileKeyCredential: uuid(),
profileKeyVersion: '', profileKeyVersion: '',
profileSharing: true,
quotedMessageId: null, quotedMessageId: null,
sealedSender: 1, sealedSender: 1,
sentMessageCount: 1, sentMessageCount: 1,
sharedGroupNames: [], sharedGroupNames: [],
id: uuid(),
type: 'private',
timestamp: Date.now(), timestamp: Date.now(),
active_at: Date.now(), type: 'private',
uuid: uuid(),
version: 2, version: 2,
...overrideAttributes, ...overrideAttributes,
}); });
sinonSandbox.stub(result, 'getProfile').resolves(undefined);
return result; return result;
} }
@ -87,8 +89,7 @@ describe('routineProfileRefresh', () => {
storage, storage,
}); });
sinon.assert.notCalled(conversation1.getProfile as sinon.SinonStub); sinon.assert.notCalled(getProfileStub.getProfile as sinon.SinonStub);
sinon.assert.notCalled(conversation2.getProfile as sinon.SinonStub);
sinon.assert.notCalled(storage.put); sinon.assert.notCalled(storage.put);
}); });
@ -102,8 +103,16 @@ describe('routineProfileRefresh', () => {
storage: makeStorage(), storage: makeStorage(),
}); });
sinon.assert.calledOnce(conversation1.getProfile as sinon.SinonStub); sinon.assert.calledWith(
sinon.assert.calledOnce(conversation2.getProfile as sinon.SinonStub); getProfileStub.getProfile as sinon.SinonStub,
conversation1.get('uuid'),
conversation1.get('e164')
);
sinon.assert.calledWith(
getProfileStub.getProfile as sinon.SinonStub,
conversation2.get('uuid'),
conversation2.get('e164')
);
}); });
it("skips conversations that haven't been active in 30 days", async () => { it("skips conversations that haven't been active in 30 days", async () => {
@ -119,9 +128,22 @@ describe('routineProfileRefresh', () => {
storage: makeStorage(), storage: makeStorage(),
}); });
sinon.assert.calledOnce(recentlyActive.getProfile as sinon.SinonStub); sinon.assert.calledOnce(getProfileStub.getProfile as sinon.SinonStub);
sinon.assert.notCalled(inactive.getProfile as sinon.SinonStub); sinon.assert.calledWith(
sinon.assert.notCalled(neverActive.getProfile as sinon.SinonStub); getProfileStub.getProfile as sinon.SinonStub,
recentlyActive.get('uuid'),
recentlyActive.get('e164')
);
sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
inactive.get('uuid'),
inactive.get('e164')
);
sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
neverActive.get('uuid'),
neverActive.get('e164')
);
}); });
it('skips your own conversation', async () => { it('skips your own conversation', async () => {
@ -134,7 +156,16 @@ describe('routineProfileRefresh', () => {
storage: makeStorage(), storage: makeStorage(),
}); });
sinon.assert.notCalled(me.getProfile as sinon.SinonStub); sinon.assert.calledWith(
getProfileStub.getProfile as sinon.SinonStub,
notMe.get('uuid'),
notMe.get('e164')
);
sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
me.get('uuid'),
me.get('e164')
);
}); });
it('skips conversations that were refreshed in the last hour', async () => { it('skips conversations that were refreshed in the last hour', async () => {
@ -149,8 +180,17 @@ describe('routineProfileRefresh', () => {
storage: makeStorage(), storage: makeStorage(),
}); });
sinon.assert.calledOnce(neverRefreshed.getProfile as sinon.SinonStub); sinon.assert.calledOnce(getProfileStub.getProfile as sinon.SinonStub);
sinon.assert.notCalled(recentlyFetched.getProfile as sinon.SinonStub); sinon.assert.calledWith(
getProfileStub.getProfile as sinon.SinonStub,
neverRefreshed.get('uuid'),
neverRefreshed.get('e164')
);
sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
recentlyFetched.get('uuid'),
recentlyFetched.get('e164')
);
}); });
it('"digs into" the members of an active group', async () => { it('"digs into" the members of an active group', async () => {
@ -182,15 +222,31 @@ describe('routineProfileRefresh', () => {
storage: makeStorage(), storage: makeStorage(),
}); });
sinon.assert.calledOnce(privateConversation.getProfile as sinon.SinonStub); sinon.assert.calledWith(
sinon.assert.calledOnce( getProfileStub.getProfile as sinon.SinonStub,
recentlyActiveGroupMember.getProfile as sinon.SinonStub privateConversation.get('uuid'),
privateConversation.get('e164')
); );
sinon.assert.calledOnce(inactiveGroupMember.getProfile as sinon.SinonStub); sinon.assert.calledWith(
sinon.assert.notCalled( getProfileStub.getProfile as sinon.SinonStub,
memberWhoHasRecentlyRefreshed.getProfile as sinon.SinonStub recentlyActiveGroupMember.get('uuid'),
recentlyActiveGroupMember.get('e164')
);
sinon.assert.calledWith(
getProfileStub.getProfile as sinon.SinonStub,
inactiveGroupMember.get('uuid'),
inactiveGroupMember.get('e164')
);
sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
memberWhoHasRecentlyRefreshed.get('uuid'),
memberWhoHasRecentlyRefreshed.get('e164')
);
sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
groupConversation.get('uuid'),
groupConversation.get('e164')
); );
sinon.assert.notCalled(groupConversation.getProfile as sinon.SinonStub);
}); });
it('only refreshes profiles for the 50 most recently active direct conversations', async () => { it('only refreshes profiles for the 50 most recently active direct conversations', async () => {
@ -235,11 +291,19 @@ describe('routineProfileRefresh', () => {
}); });
[...activeConversations, ...inactiveGroupMembers].forEach(conversation => { [...activeConversations, ...inactiveGroupMembers].forEach(conversation => {
sinon.assert.calledOnce(conversation.getProfile as sinon.SinonStub); sinon.assert.calledWith(
getProfileStub.getProfile as sinon.SinonStub,
conversation.get('uuid'),
conversation.get('e164')
);
}); });
[me, ...shouldNotBeIncluded].forEach(conversation => { [me, ...shouldNotBeIncluded].forEach(conversation => {
sinon.assert.notCalled(conversation.getProfile as sinon.SinonStub); sinon.assert.neverCalledWith(
getProfileStub.getProfile as sinon.SinonStub,
conversation.get('uuid'),
conversation.get('e164')
);
}); });
}); });
}); });

View file

@ -1208,6 +1208,31 @@ export default class MessageSender {
}); });
} }
async sendFetchLocalProfileSyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType> {
const myUuid = window.textsecure.storage.user.getUuid();
const myNumber = window.textsecure.storage.user.getNumber();
const fetchLatest = new Proto.SyncMessage.FetchLatest();
fetchLatest.type = Proto.SyncMessage.FetchLatest.Type.LOCAL_PROFILE;
const syncMessage = this.createSyncMessage();
syncMessage.fetchLatest = fetchLatest;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
async sendRequestKeySyncMessage( async sendRequestKeySyncMessage(
options?: SendOptionsType options?: SendOptionsType
): Promise<CallbackResultType> { ): Promise<CallbackResultType> {

9
ts/types/SealedSender.ts Normal file
View file

@ -0,0 +1,9 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export enum SEALED_SENDER {
UNKNOWN = 0,
ENABLED = 1,
DISABLED = 2,
UNRESTRICTED = 3,
}

269
ts/util/getProfile.ts Normal file
View file

@ -0,0 +1,269 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
import { SEALED_SENDER } from '../types/SealedSender';
import {
base64ToArrayBuffer,
stringFromBytes,
trimForDisplay,
verifyAccessKey,
} from '../Crypto';
import {
generateProfileKeyCredentialRequest,
getClientZkProfileOperations,
handleProfileKeyCredential,
} from './zkgroup';
import { getSendOptions } from './getSendOptions';
import { isMe } from './whatTypeOfConversation';
export async function getProfile(
providedUuid?: string,
providedE164?: string
): Promise<void> {
if (!window.textsecure.messaging) {
throw new Error(
'Conversation.getProfile: window.textsecure.messaging not available'
);
}
const id = window.ConversationController.ensureContactIds({
uuid: providedUuid,
e164: providedE164,
});
const c = window.ConversationController.get(id);
if (!c) {
window.log.error('getProfile: failed to find conversation; doing nothing');
return;
}
const clientZkProfileCipher = getClientZkProfileOperations(
window.getServerPublicParams()
);
let profile;
try {
await Promise.all([
c.deriveAccessKeyIfNeeded(),
c.deriveProfileKeyVersionIfNeeded(),
]);
const profileKey = c.get('profileKey');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const uuid = c.get('uuid')!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identifier = c.getSendTarget()!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const profileKeyVersionHex = c.get('profileKeyVersion')!;
const existingProfileKeyCredential = c.get('profileKeyCredential');
let profileKeyCredentialRequestHex: undefined | string;
let profileCredentialRequestContext:
| undefined
| ProfileKeyCredentialRequestContext;
if (
profileKey &&
uuid &&
profileKeyVersionHex &&
!existingProfileKeyCredential
) {
window.log.info('Generating request...');
({
requestHex: profileKeyCredentialRequestHex,
context: profileCredentialRequestContext,
} = generateProfileKeyCredentialRequest(
clientZkProfileCipher,
uuid,
profileKey
));
}
const { sendMetadata = {} } = await getSendOptions(c.attributes);
const getInfo =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};
if (getInfo.accessKey) {
try {
profile = await window.textsecure.messaging.getProfile(identifier, {
accessKey: getInfo.accessKey,
profileKeyVersion: profileKeyVersionHex,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
});
} catch (error) {
if (error.code === 401 || error.code === 403) {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
);
c.set({ sealedSender: SEALED_SENDER.DISABLED });
profile = await window.textsecure.messaging.getProfile(identifier, {
profileKeyVersion: profileKeyVersionHex,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
});
} else {
throw error;
}
}
} else {
profile = await window.textsecure.messaging.getProfile(identifier, {
profileKeyVersion: profileKeyVersionHex,
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
});
}
const identityKey = base64ToArrayBuffer(profile.identityKey);
const changed = await window.textsecure.storage.protocol.saveIdentity(
`${identifier}.1`,
identityKey,
false
);
if (changed) {
// save identity will close all sessions except for .1, so we
// must close that one manually.
await window.textsecure.storage.protocol.archiveSession(
`${identifier}.1`
);
}
const accessKey = c.get('accessKey');
if (profile.unrestrictedUnidentifiedAccess && profile.unidentifiedAccess) {
window.log.info(
`Setting sealedSender to UNRESTRICTED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.UNRESTRICTED,
});
} else if (accessKey && profile.unidentifiedAccess) {
const haveCorrectKey = await verifyAccessKey(
base64ToArrayBuffer(accessKey),
base64ToArrayBuffer(profile.unidentifiedAccess)
);
if (haveCorrectKey) {
window.log.info(
`Setting sealedSender to ENABLED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.ENABLED,
});
} else {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.DISABLED,
});
}
} else {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
);
c.set({
sealedSender: SEALED_SENDER.DISABLED,
});
}
if (profile.about) {
const key = c.get('profileKey');
if (key) {
const keyBuffer = base64ToArrayBuffer(key);
const decrypted = await window.textsecure.crypto.decryptProfile(
base64ToArrayBuffer(profile.about),
keyBuffer
);
c.set('about', stringFromBytes(trimForDisplay(decrypted)));
}
} else {
c.unset('about');
}
if (profile.aboutEmoji) {
const key = c.get('profileKey');
if (key) {
const keyBuffer = base64ToArrayBuffer(key);
const decrypted = await window.textsecure.crypto.decryptProfile(
base64ToArrayBuffer(profile.aboutEmoji),
keyBuffer
);
c.set('aboutEmoji', stringFromBytes(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');
}
if (profileCredentialRequestContext) {
if (profile.credential) {
const profileKeyCredential = handleProfileKeyCredential(
clientZkProfileCipher,
profileCredentialRequestContext,
profile.credential
);
c.set({ profileKeyCredential });
} else {
c.unset('profileKeyCredential');
}
}
} catch (error) {
switch (error?.code) {
case 403:
throw error;
case 404:
window.log.warn(
`getProfile failure: failed to find a profile for ${c.idForLogging()}`,
error && error.stack ? error.stack : error
);
c.setUnregistered();
return;
default:
window.log.warn(
'getProfile failure:',
c.idForLogging(),
error && error.stack ? error.stack : error
);
return;
}
}
try {
await c.setEncryptedProfileName(profile.name);
} catch (error) {
window.log.warn(
'getProfile decryption failure:',
c.idForLogging(),
error && error.stack ? error.stack : error
);
await c.set({
profileName: undefined,
profileFamilyName: undefined,
});
}
try {
await c.setProfileAvatar(profile.avatar);
} catch (error) {
if (error.code === 403 || error.code === 404) {
window.log.info(
`Clearing profile avatar for conversation ${c.idForLogging()}`
);
c.set({
profileAvatar: null,
});
}
}
c.set('profileLastFetchedAt', Date.now());
window.Signal.Data.updateConversation(c.attributes);
}

View file

@ -2412,7 +2412,7 @@ Whisper.ConversationView = Whisper.View.extend({
conversation => conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin() conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
); );
if (!cannotSend) { if (cannotSend) {
throw new Error('Cannot send to group'); throw new Error('Cannot send to group');
} }