From f5b6852f39749498da8c56f1317ace9407c4a6fb Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:02:53 -0600 Subject: [PATCH] Support device name change sync message Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- protos/SignalService.proto | 6 ++ ts/background.ts | 14 ++++ ts/textsecure/AccountManager.ts | 1 + ts/textsecure/MessageReceiver.ts | 45 +++++++++++++ ts/textsecure/WebAPI.ts | 33 +++++++--- ts/textsecure/messageReceiverEvents.ts | 6 ++ ts/textsecure/storage/User.ts | 8 ++- ts/util/handleMessageSend.ts | 1 + ts/util/onDeviceNameChangeSync.ts | 88 ++++++++++++++++++++++++++ 9 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 ts/util/onDeviceNameChangeSync.ts diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 9bc9c155294a..53f487ea4f4d 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -700,6 +700,11 @@ message SyncMessage { repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; repeated AttachmentDelete attachmentDeletes = 4; } + + message DeviceNameChange { + reserved /*name*/ 1; + optional uint32 deviceId = 2; + } optional Sent sent = 1; optional Contacts contacts = 2; @@ -723,6 +728,7 @@ message SyncMessage { optional CallLinkUpdate callLinkUpdate = 20; optional CallLogEvent callLogEvent = 21; optional DeleteForMe deleteForMe = 22; + optional DeviceNameChange deviceNameChange = 23; } message AttachmentPointer { diff --git a/ts/background.ts b/ts/background.ts index 2a67b9d619d9..2dae79cd3034 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -199,6 +199,10 @@ import { getParametersForRedux, loadAll } from './services/allLoaders'; import { checkFirstEnvelope } from './util/checkFirstEnvelope'; import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked'; import { ReleaseNotesFetcher } from './services/releaseNotesFetcher'; +import { + maybeQueueDeviceNameFetch, + onDeviceNameChangeSync, +} from './util/onDeviceNameChangeSync'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -697,6 +701,10 @@ export async function startApp(): Promise { 'deleteForMeSync', queuedEventListener(onDeleteForMeSync, false) ); + messageReceiver.addEventListener( + 'deviceNameChangeSync', + queuedEventListener(onDeviceNameChangeSync, false) + ); if (!window.storage.get('defaultConversationColor')) { drop( @@ -1924,6 +1932,12 @@ export async function startApp(): Promise { Errors.toLogFormat(error) ); } + + // Ensure we have the correct device name locally (allowing us to get eventually + // consistent with primary, in case we failed to process a deviceNameChangeSync + // for some reason). We do this after calling `maybeUpdateDeviceName` to ensure + // that the device name on server is encrypted. + drop(maybeQueueDeviceNameFetch()); } if (firstRun === true && !areWePrimaryDevice) { diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index f020f74c2579..3ecd8631056d 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -315,6 +315,7 @@ export default class AccountManager extends EventTarget { if (base64) { await this.server.updateDeviceName(base64); + await window.textsecure.storage.user.setDeviceNameEncrypted(); } } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 0785f3c21e17..b3931e97ab0e 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -113,6 +113,7 @@ import { DecryptionErrorEvent, DeleteForMeSyncEvent, DeliveryEvent, + DeviceNameChangeSyncEvent, EmptyEvent, EnvelopeQueuedEvent, EnvelopeUnsealedEvent, @@ -701,6 +702,11 @@ export default class MessageReceiver handler: (ev: DeleteForMeSyncEvent) => void ): void; + public override addEventListener( + name: 'deviceNameChangeSync', + handler: (ev: DeviceNameChangeSyncEvent) => void + ): void; + public override addEventListener(name: string, handler: EventHandler): void { return super.addEventListener(name, handler); } @@ -3191,6 +3197,12 @@ export default class MessageReceiver if (syncMessage.deleteForMe) { return this.handleDeleteForMeSync(envelope, syncMessage.deleteForMe); } + if (syncMessage.deviceNameChange) { + return this.handleDeviceNameChangeSync( + envelope, + syncMessage.deviceNameChange + ); + } this.removeFromCache(envelope); const envelopeId = getEnvelopeId(envelope); @@ -3817,6 +3829,39 @@ export default class MessageReceiver log.info('handleDeleteForMeSync: finished'); } + private async handleDeviceNameChangeSync( + envelope: ProcessedEnvelope, + deviceNameChange: Proto.SyncMessage.IDeviceNameChange + ): Promise { + const logId = `MessageReceiver.handleDeviceNameChangeSync: ${getEnvelopeId(envelope)}`; + log.info(logId); + + logUnexpectedUrgentValue(envelope, 'deviceNameChangeSync'); + + const { deviceId } = deviceNameChange; + const localDeviceId = parseIntOrThrow( + this.storage.user.getDeviceId(), + 'MessageReceiver.handleDeviceNameChangeSync: localDeviceId' + ); + + if (deviceId == null) { + log.warn(logId, 'deviceId was falsey'); + this.removeFromCache(envelope); + return; + } + + if (deviceId !== localDeviceId) { + log.info(logId, 'meant for other device:', deviceId); + this.removeFromCache(envelope); + return; + } + + const deviceNameChangeEvent = new DeviceNameChangeSyncEvent( + this.removeFromCache.bind(this, envelope) + ); + await this.dispatchAndWait(logId, deviceNameChangeEvent); + } + private async handleContacts( envelope: ProcessedEnvelope, contactSyncProto: Proto.SyncMessage.IContacts diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index e6c7c9c82598..16bae39c1fb8 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -862,6 +862,19 @@ export type GetAccountForUsernameResultType = z.infer< typeof getAccountForUsernameResultZod >; +const getDevicesResultZod = z.object({ + devices: z.array( + z.object({ + id: z.number(), + name: z.string().nullish(), // primary devices may not have a name + lastSeen: z.number().nullish(), + created: z.number().nullish(), + }) + ), +}); + +export type GetDevicesResultType = z.infer; + export type GetIceServersResultType = Readonly<{ relays?: ReadonlyArray; }>; @@ -874,15 +887,6 @@ export type IceServerGroupType = Readonly<{ hostname?: string; }>; -export type GetDevicesResultType = ReadonlyArray< - Readonly<{ - id: number; - name: string; - lastSeen: number; - created: number; - }> ->; - export type GetSenderCertificateResultType = Readonly<{ certificate: string }>; export type MakeProxiedRequestResultType = @@ -1376,6 +1380,7 @@ export type WebAPIType = { getAccountForUsername: ( options: GetAccountForUsernameOptionsType ) => Promise; + getDevices: () => Promise; getProfileUnauth: ( serviceId: ServiceIdString, options: ProfileFetchUnauthRequestOptions @@ -1847,6 +1852,7 @@ export function initialize({ getBackupUploadForm, getBadgeImageFile, getConfig, + getDevices, getGroup, getGroupAvatar, getGroupCredentials, @@ -2935,6 +2941,15 @@ export function initialize({ }); } + async function getDevices() { + const result = await _ajax({ + call: 'devices', + httpType: 'GET', + responseType: 'json', + }); + return parseUnknown(getDevicesResultZod, result); + } + async function updateDeviceName(deviceName: string) { await _ajax({ call: 'updateDeviceName', diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 5f391343ce76..289cd8cca141 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -493,6 +493,12 @@ export class CallLinkUpdateSyncEvent extends ConfirmableEvent { } } +export class DeviceNameChangeSyncEvent extends ConfirmableEvent { + constructor(confirm: ConfirmCallback) { + super('deviceNameChangeSync', confirm); + } +} + const messageToDeleteSchema = z.union([ z.object({ type: z.literal('aci').readonly(), diff --git a/ts/textsecure/storage/User.ts b/ts/textsecure/storage/User.ts index 4cd210353661..da3ae667c0ea 100644 --- a/ts/textsecure/storage/User.ts +++ b/ts/textsecure/storage/User.ts @@ -153,6 +153,10 @@ export class User { return this.storage.get('device_name'); } + public async setDeviceName(name: string): Promise { + return this.storage.put('device_name', name); + } + public async setDeviceNameEncrypted(): Promise { return this.storage.put('deviceNameEncrypted', true); } @@ -175,9 +179,7 @@ export class User { this.storage.put('uuid_id', `${aci}.${deviceId}`), this.storage.put('password', password), this.setPni(pni), - deviceName - ? this.storage.put('device_name', deviceName) - : Promise.resolve(), + deviceName ? this.setDeviceName(deviceName) : Promise.resolve(), ]); } diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index c00d2ccae83f..5ff7bf99f905 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -69,6 +69,7 @@ export const sendTypesEnum = z.enum([ 'callEventSync', 'callLinkUpdateSync', 'callLogEventSync', + 'deviceNameChangeSync', // No longer used, all non-urgent 'legacyGroupChange', diff --git a/ts/util/onDeviceNameChangeSync.ts b/ts/util/onDeviceNameChangeSync.ts new file mode 100644 index 000000000000..8d6748235ed7 --- /dev/null +++ b/ts/util/onDeviceNameChangeSync.ts @@ -0,0 +1,88 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import PQueue from 'p-queue'; +import type { DeviceNameChangeSyncEvent } from '../textsecure/messageReceiverEvents'; +import { MINUTE } from './durations'; +import { strictAssert } from './assert'; +import { parseIntOrThrow } from './parseIntOrThrow'; +import * as log from '../logging/log'; +import { toLogFormat } from '../types/errors'; +import { drop } from './drop'; + +const deviceNameFetchQueue = new PQueue({ + concurrency: 1, + timeout: 5 * MINUTE, + throwOnTimeout: true, +}); + +export async function onDeviceNameChangeSync( + event: DeviceNameChangeSyncEvent +): Promise { + const { confirm } = event; + + const maybeQueueAndThenConfirm = async () => { + await maybeQueueDeviceNameFetch(); + confirm(); + }; + + drop(maybeQueueAndThenConfirm()); +} + +export async function maybeQueueDeviceNameFetch(): Promise { + if (deviceNameFetchQueue.size >= 1) { + log.info('maybeQueueDeviceNameFetch: skipping; fetch already queued'); + } + + try { + await deviceNameFetchQueue.add(fetchAndUpdateDeviceName); + } catch (e) { + log.error( + 'maybeQueueDeviceNameFetch: error when fetching device name', + toLogFormat(e) + ); + } +} + +async function fetchAndUpdateDeviceName() { + strictAssert(window.textsecure.server, 'WebAPI must be initialized'); + const { devices } = await window.textsecure.server.getDevices(); + const localDeviceId = parseIntOrThrow( + window.textsecure.storage.user.getDeviceId(), + 'fetchAndUpdateDeviceName: localDeviceId' + ); + const ourDevice = devices.find(device => device.id === localDeviceId); + strictAssert(ourDevice, 'ourDevice must be returned from devices endpoint'); + + const newNameEncrypted = ourDevice.name; + + if (!newNameEncrypted) { + log.error('fetchAndUpdateDeviceName: device had empty name'); + return; + } + + let newName: string; + try { + newName = await window + .getAccountManager() + .decryptDeviceName(newNameEncrypted); + } catch (e) { + const deviceNameWasEncrypted = + window.textsecure.storage.user.getDeviceNameEncrypted(); + log.error( + `fetchAndUpdateDeviceName: failed to decrypt device name. Was encrypted local state: ${deviceNameWasEncrypted}` + ); + return; + } + + const existingName = window.storage.user.getDeviceName(); + if (newName === existingName) { + log.info('fetchAndUpdateDeviceName: new name matches existing name'); + return; + } + + await window.storage.user.setDeviceName(newName); + log.info( + 'fetchAndUpdateDeviceName: successfully updated new device name locally' + ); +}