Support phone number sharing flag on profile

This commit is contained in:
Fedor Indutny 2024-01-02 20:36:49 +01:00 committed by GitHub
parent 23f39a0dc7
commit d71da5c486
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 155 additions and 33 deletions

1
ts/model-types.d.ts vendored
View file

@ -333,6 +333,7 @@ export type ConversationAttributesType = {
messageRequestResponseType?: number; messageRequestResponseType?: number;
muteExpiresAt?: number; muteExpiresAt?: number;
dontNotifyForMentionsIfMuted?: boolean; dontNotifyForMentionsIfMuted?: boolean;
notSharingPhoneNumber?: boolean;
profileAvatar?: ContactAvatarType | null; profileAvatar?: ContactAvatarType | null;
profileKeyCredential?: string | null; profileKeyCredential?: string | null;
profileKeyCredentialExpiration?: number | null; profileKeyCredentialExpiration?: number | null;

View file

@ -1858,7 +1858,7 @@ export class ConversationModel extends window.Backbone
this.set('e164', e164 || undefined); this.set('e164', e164 || undefined);
// This user changed their phone number // This user changed their phone number
if (oldValue && e164) { if (oldValue && e164 && !this.get('notSharingPhoneNumber')) {
void this.addChangeNumberNotification(oldValue, e164); void this.addChangeNumberNotification(oldValue, e164);
} }

View file

@ -427,6 +427,24 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
c.unset('aboutEmoji'); 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 only
// set `notSharingPhoneNumber` to `true` in all cases except [0x01].
c.set(
'notSharingPhoneNumber',
decrypted.length !== 1 || decrypted[0] !== 1
);
}
} else {
c.unset('notSharingPhoneNumber');
}
if (profile.paymentAddress && isMe(c.attributes)) { if (profile.paymentAddress && isMe(c.attributes)) {
await window.storage.put('paymentAddress', profile.paymentAddress); await window.storage.put('paymentAddress', profile.paymentAddress);
} }

View file

@ -2,13 +2,18 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { getConversation } from '../util/getConversation';
import { DAY } from '../util/durations'; import { DAY } from '../util/durations';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff'; import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
import { checkForUsername } from '../util/lookupConversationWithoutServiceId'; import { checkForUsername } from '../util/lookupConversationWithoutServiceId';
import { storageJobQueue } from '../util/JobQueue'; import { storageJobQueue } from '../util/JobQueue';
import { getProfile } from '../util/getProfile';
import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { resolveUsernameByLink } from './username'; import { resolveUsernameByLink } from './username';
import { runStorageServiceSyncJob } from './storage';
import { writeProfile } from './writeProfile';
const CHECK_INTERVAL = DAY; const CHECK_INTERVAL = DAY;
@ -59,6 +64,11 @@ class UsernameIntegrityService {
} }
private async check(): Promise<void> { private async check(): Promise<void> {
await this.checkUsername();
await this.checkPhoneNumberSharing();
}
private async checkUsername(): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow(); const me = window.ConversationController.getOurConversationOrThrow();
const username = me.get('username'); const username = me.get('username');
const aci = me.getAci(); const aci = me.getAci();
@ -100,6 +110,51 @@ class UsernameIntegrityService {
log.info('usernameIntegrity: check pass'); log.info('usernameIntegrity: check pass');
} }
} }
private async checkPhoneNumberSharing(): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow();
await getProfile(me.getServiceId(), me.get('e164'));
{
const localValue = isSharingPhoneNumberWithEverybody();
const remoteValue = !me.get('notSharingPhoneNumber');
if (localValue === remoteValue) {
return;
}
log.warn(
'usernameIntegrity: phone number sharing mode conflict, running ' +
`storage service sync (local: ${localValue}, remote: ${remoteValue})`
);
runStorageServiceSyncJob();
}
window.Whisper.events.once('storageService:syncComplete', () => {
const localValue = isSharingPhoneNumberWithEverybody();
const remoteValue = !me.get('notSharingPhoneNumber');
if (localValue === remoteValue) {
log.info(
'usernameIntegrity: phone number sharing mode conflict resolved by ' +
'storage service sync'
);
return;
}
log.warn(
'usernameIntegrity: phone number sharing mode conflict not resolved, ' +
'updating profile'
);
drop(
writeProfile(getConversation(me), {
oldAvatar: undefined,
newAvatar: undefined,
})
);
});
}
} }
export const usernameIntegrity = new UsernameIntegrityService(); export const usernameIntegrity = new UsernameIntegrityService();

View file

@ -18,9 +18,9 @@ export async function writeProfile(
conversation: ConversationType, conversation: ConversationType,
avatar: AvatarUpdateType avatar: AvatarUpdateType
): Promise<void> { ): Promise<void> {
const { messaging } = window.textsecure; const { server } = window.textsecure;
if (!messaging) { if (!server) {
throw new Error('messaging is not available!'); throw new Error('server is not available!');
} }
// Before we write anything we request the user's profile so that we can // Before we write anything we request the user's profile so that we can
@ -50,7 +50,7 @@ export async function writeProfile(
conversation, conversation,
avatar avatar
); );
const avatarRequestHeaders = await messaging.putProfile(profileData); const avatarRequestHeaders = await server.putProfile(profileData);
// Upload the avatar if provided // Upload the avatar if provided
// delete existing files on disk if avatar has been removed // delete existing files on disk if avatar has been removed
@ -68,7 +68,7 @@ export async function writeProfile(
log.info('writeProfile: not updating avatar'); log.info('writeProfile: not updating avatar');
} else if (avatarRequestHeaders && encryptedAvatarData && newAvatar) { } else if (avatarRequestHeaders && encryptedAvatarData && newAvatar) {
log.info('writeProfile: uploading new avatar'); log.info('writeProfile: uploading new avatar');
const avatarUrl = await messaging.uploadAvatar( const avatarUrl = await server.uploadAvatar(
avatarRequestHeaders, avatarRequestHeaders,
encryptedAvatarData encryptedAvatarData
); );

View file

@ -308,6 +308,7 @@ export type ConversationType = ReadonlyDeep<
typingContactIdTimestamps?: Record<string, number>; typingContactIdTimestamps?: Record<string, number>;
recentMediaItems?: ReadonlyArray<MediaItemType>; recentMediaItems?: ReadonlyArray<MediaItemType>;
profileSharing?: boolean; profileSharing?: boolean;
notSharingPhoneNumber?: boolean;
shouldShowDraft?: boolean; shouldShowDraft?: boolean;
// Full information for re-hydrating composition area // Full information for re-hydrating composition area

View file

@ -34,9 +34,7 @@ import type {
GetProfileUnauthOptionsType, GetProfileUnauthOptionsType,
GroupCredentialsType, GroupCredentialsType,
GroupLogResponseType, GroupLogResponseType,
ProfileRequestDataType,
ProxiedRequestOptionsType, ProxiedRequestOptionsType,
UploadAvatarHeadersType,
WebAPIType, WebAPIType,
} from './WebAPI'; } from './WebAPI';
import createTaskWithTimeout from './TaskWithTimeout'; import createTaskWithTimeout from './TaskWithTimeout';
@ -2232,17 +2230,4 @@ export default class MessageSender {
): Promise<void> { ): Promise<void> {
return this.server.sendChallengeResponse(challengeResponse); return this.server.sendChallengeResponse(challengeResponse);
} }
async putProfile(
jsonData: Readonly<ProfileRequestDataType>
): Promise<UploadAvatarHeadersType | undefined> {
return this.server.putProfile(jsonData);
}
async uploadAvatar(
requestHeaders: Readonly<UploadAvatarHeadersType>,
avatarData: Readonly<Uint8Array>
): Promise<string> {
return this.server.uploadAvatar(requestHeaders, avatarData);
}
} }

View file

@ -674,6 +674,7 @@ export type ProfileRequestDataType = {
commitment: string; commitment: string;
name: string; name: string;
paymentAddress: string | null; paymentAddress: string | null;
phoneNumberSharing: string | null;
version: string; version: string;
}; };
@ -706,6 +707,7 @@ export type ProfileType = Readonly<{
about?: string; about?: string;
aboutEmoji?: string; aboutEmoji?: string;
avatar?: string; avatar?: string;
phoneNumberSharing?: string;
unidentifiedAccess?: string; unidentifiedAccess?: string;
unrestrictedUnidentifiedAccess?: string; unrestrictedUnidentifiedAccess?: string;
uuid?: string; uuid?: string;

View file

@ -21,6 +21,7 @@ import type { ConversationType } from '../state/ducks/conversations';
import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals'; import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals';
import { calling } from '../services/calling'; import { calling } from '../services/calling';
import { resolveUsernameByLinkBase64 } from '../services/username'; import { resolveUsernameByLinkBase64 } from '../services/username';
import { writeProfile } from '../services/writeProfile';
import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations'; import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations';
import { getCustomColors } from '../state/selectors/items'; import { getCustomColors } from '../state/selectors/items';
import { themeChanged } from '../shims/themeChanged'; import { themeChanged } from '../shims/themeChanged';
@ -41,6 +42,7 @@ import type { NotificationClickData } from '../services/notifications';
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories'; import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
import { isValidE164 } from './isValidE164'; import { isValidE164 } from './isValidE164';
import { fromWebSafeBase64 } from './webSafeBase64'; import { fromWebSafeBase64 } from './webSafeBase64';
import { getConversation } from './getConversation';
type SentMediaQualityType = 'standard' | 'high'; type SentMediaQualityType = 'standard' | 'high';
type ThemeType = 'light' | 'dark' | 'system'; type ThemeType = 'light' | 'dark' | 'system';
@ -235,6 +237,14 @@ export function createIPCEvents(
setPhoneNumberDiscoverabilitySetting, setPhoneNumberDiscoverabilitySetting,
setPhoneNumberSharingSetting: async (newValue: PhoneNumberSharingMode) => { setPhoneNumberSharingSetting: async (newValue: PhoneNumberSharingMode) => {
const account = window.ConversationController.getOurConversationOrThrow(); const account = window.ConversationController.getOurConversationOrThrow();
// writeProfile fetches the latest profile first so do it before updating
// local data to prevent triggering a conflict.
await writeProfile(getConversation(account), {
oldAvatar: undefined,
newAvatar: undefined,
});
const promises = new Array<Promise<void>>(); const promises = new Array<Promise<void>>();
promises.push(window.storage.put('phoneNumberSharingMode', newValue)); promises.push(window.storage.put('phoneNumberSharingMode', newValue));
if (newValue === PhoneNumberSharingMode.Everybody) { if (newValue === PhoneNumberSharingMode.Everybody) {

View file

@ -12,6 +12,7 @@ import {
} from '../Crypto'; } from '../Crypto';
import type { AvatarUpdateType } from '../types/Avatar'; import type { AvatarUpdateType } from '../types/Avatar';
import { deriveProfileKeyCommitment, deriveProfileKeyVersion } from './zkgroup'; import { deriveProfileKeyCommitment, deriveProfileKeyVersion } from './zkgroup';
import { isSharingPhoneNumberWithEverybody } from './phoneNumberSharingMode';
export async function encryptProfileData( export async function encryptProfileData(
conversation: ConversationType, conversation: ConversationType,
@ -56,6 +57,11 @@ export async function encryptProfileData(
) )
: null; : null;
const encryptedPhoneNumberSharing = encryptProfile(
new Uint8Array([isSharingPhoneNumberWithEverybody() ? 1 : 0]),
keyBuffer
);
const encryptedAvatarData = newAvatar const encryptedAvatarData = newAvatar
? encryptProfile(newAvatar, keyBuffer) ? encryptProfile(newAvatar, keyBuffer)
: undefined; : undefined;
@ -72,6 +78,7 @@ export async function encryptProfileData(
avatar: Boolean(newAvatar), avatar: Boolean(newAvatar),
sameAvatar, sameAvatar,
commitment: deriveProfileKeyCommitment(profileKey, serviceId), commitment: deriveProfileKeyCommitment(profileKey, serviceId),
phoneNumberSharing: Bytes.toBase64(encryptedPhoneNumberSharing),
}; };
return [profileData, encryptedAvatarData]; return [profileData, encryptedAvatarData];

View file

@ -8,6 +8,7 @@ import { parseAndFormatPhoneNumber } from './libphonenumberInstance';
import { WEEK } from './durations'; import { WEEK } from './durations';
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse'; import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse';
import { countConversationUnreadStats, hasUnread } from './countUnreadStats'; import { countConversationUnreadStats, hasUnread } from './countUnreadStats';
import { getE164 } from './getE164';
// Fuse.js scores have order of 0.01 // Fuse.js scores have order of 0.01
const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01; const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01;
@ -46,7 +47,13 @@ const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationType> = {
weight: 0.5, weight: 0.5,
}, },
], ],
getFn: fuseGetFnRemoveDiacritics, getFn: (convo, path) => {
if (path === 'e164' || (path.length === 1 && path[0] === 'e164')) {
return getE164(convo) ?? '';
}
return fuseGetFnRemoveDiacritics(convo, path);
},
}; };
type CommandRunnerType = ( type CommandRunnerType = (

View file

@ -210,6 +210,7 @@ export function getConversation(model: ConversationModel): ConversationType {
phoneNumber: getNumber(attributes), phoneNumber: getNumber(attributes),
profileName: getProfileName(attributes), profileName: getProfileName(attributes),
profileSharing: attributes.profileSharing, profileSharing: attributes.profileSharing,
notSharingPhoneNumber: attributes.notSharingPhoneNumber,
publicParams: attributes.publicParams, publicParams: attributes.publicParams,
secretParams: attributes.secretParams, secretParams: attributes.secretParams,
shouldShowDraft, shouldShowDraft,

26
ts/util/getE164.ts Normal file
View file

@ -0,0 +1,26 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { ConversationType } from '../state/ducks/conversations';
import { isInSystemContacts } from './isInSystemContacts';
export function getE164(
attributes: Pick<
ConversationAttributesType | ConversationType,
| 'type'
| 'name'
| 'systemGivenName'
| 'systemFamilyName'
| 'e164'
| 'notSharingPhoneNumber'
>
): string | undefined {
const { e164, notSharingPhoneNumber = false } = attributes;
if (notSharingPhoneNumber && !isInSystemContacts(attributes)) {
return undefined;
}
return e164;
}

View file

@ -8,6 +8,7 @@ import type {
import { combineNames } from './combineNames'; import { combineNames } from './combineNames';
import { getRegionCodeForNumber } from './libphonenumberUtil'; import { getRegionCodeForNumber } from './libphonenumberUtil';
import { isDirectConversation } from './whatTypeOfConversation'; import { isDirectConversation } from './whatTypeOfConversation';
import { getE164 } from './getE164';
export function getTitle( export function getTitle(
attributes: ConversationRenderInfoType, attributes: ConversationRenderInfoType,
@ -107,13 +108,16 @@ export function getSystemName(
} }
export function getNumber( export function getNumber(
attributes: Pick<ConversationAttributesType, 'e164' | 'type'> attributes: Pick<
ConversationAttributesType,
'e164' | 'type' | 'notSharingPhoneNumber'
>
): string | undefined { ): string | undefined {
if (!isDirectConversation(attributes)) { if (!isDirectConversation(attributes)) {
return ''; return '';
} }
const { e164 } = attributes; const e164 = getE164(attributes);
if (!e164) { if (!e164) {
return ''; return '';
} }

View file

@ -11,6 +11,7 @@ import {
getSourceServiceId, getSourceServiceId,
} from '../messages/helpers'; } from '../messages/helpers';
import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation'; import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation';
import { getE164 } from './getE164';
export function getMessageIdForLogging( export function getMessageIdForLogging(
message: Pick< message: Pick<
@ -29,8 +30,8 @@ export function getConversationIdForLogging(
conversation: ConversationAttributesType conversation: ConversationAttributesType
): string { ): string {
if (isDirectConversation(conversation)) { if (isDirectConversation(conversation)) {
const { serviceId, pni, e164, id } = conversation; const { serviceId, pni, id } = conversation;
return `${serviceId || pni || e164} (${id})`; return `${serviceId || pni || getE164(conversation)} (${id})`;
} }
if (isGroupV2(conversation)) { if (isGroupV2(conversation)) {
return `groupv2(${conversation.groupId})`; return `groupv2(${conversation.groupId})`;

View file

@ -19,13 +19,7 @@ export const parsePhoneNumberSharingMode = makeEnumParser(
PhoneNumberSharingMode.Everybody PhoneNumberSharingMode.Everybody
); );
export const shouldSharePhoneNumberWith = ( export const isSharingPhoneNumberWithEverybody = (): boolean => {
conversation: ConversationAttributesType
): boolean => {
if (!isDirectConversation(conversation) || isMe(conversation)) {
return false;
}
const phoneNumberSharingMode = parsePhoneNumberSharingMode( const phoneNumberSharingMode = parsePhoneNumberSharingMode(
window.storage.get('phoneNumberSharingMode') window.storage.get('phoneNumberSharingMode')
); );
@ -40,3 +34,13 @@ export const shouldSharePhoneNumberWith = (
throw missingCaseError(phoneNumberSharingMode); throw missingCaseError(phoneNumberSharingMode);
} }
}; };
export const shouldSharePhoneNumberWith = (
conversation: ConversationAttributesType
): boolean => {
if (!isDirectConversation(conversation) || isMe(conversation)) {
return false;
}
return isSharingPhoneNumberWithEverybody();
};