diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index bc9f5fafb1e7..0a72f59c34e7 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -722,19 +722,19 @@ export class ConversationController { return null; } - prepareForSend( + async prepareForSend( id: string | undefined, options?: WhatIsThis - ): { + ): Promise<{ wrap: ( promise: Promise ) => Promise; sendOptions: SendOptionsType | undefined; - } { + }> { // id is any valid conversation identifier const conversation = this.get(id); const sendOptions = conversation - ? conversation.getSendOptions(options) + ? await conversation.getSendOptions(options) : undefined; const wrap = conversation ? conversation.wrapSend.bind(conversation) diff --git a/ts/background.ts b/ts/background.ts index 0e7ac7e5ac69..3404d8bab895 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -7,8 +7,7 @@ import { WhatIsThis } from './window.d'; import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; import { isWindowDragElement } from './util/isWindowDragElement'; import { assert } from './util/assert'; -import * as refreshSenderCertificate from './refreshSenderCertificate'; -import { SenderCertificateMode } from './metadata/SecretSessionCipher'; +import { senderCertificateService } from './services/senderCertificate'; import { routineProfileRefresh } from './routineProfileRefresh'; import { isMoreRecentThan, isOlderThan } from './util/timestamp'; import { isValidReactionEmoji } from './reactions/isValidReactionEmoji'; @@ -30,6 +29,17 @@ export async function startApp(): Promise { err && err.stack ? err.stack : err ); } + + window.textsecure.protobuf.onLoad(() => { + senderCertificateService.initialize({ + WebAPI: window.WebAPI, + navigator, + onlineEventTarget: window, + SenderCertificate: window.textsecure.protobuf.SenderCertificate, + storage: window.storage, + }); + }); + const eventHandlerQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, @@ -70,7 +80,7 @@ export async function startApp(): Promise { const { wrap, sendOptions, - } = window.ConversationController.prepareForSend(c.get('id')); + } = await window.ConversationController.prepareForSend(c.get('id')); // eslint-disable-next-line no-await-in-loop await wrap( window.textsecure.messaging.sendDeliveryReceipt( @@ -1592,11 +1602,14 @@ export async function startApp(): Promise { ); } - window.getSyncRequest = () => - new window.textsecure.SyncRequest( + window.getSyncRequest = () => { + const syncRequest = new window.textsecure.SyncRequest( window.textsecure.messaging, messageReceiver ); + syncRequest.start(); + return syncRequest; + }; let disconnectTimer: WhatIsThis | null = null; let reconnectTimer: WhatIsThis | null = null; @@ -1948,10 +1961,7 @@ export async function startApp(): Promise { ); onChangeTheme(); } - const syncRequest = new window.textsecure.SyncRequest( - window.textsecure.messaging, - messageReceiver - ); + const syncRequest = window.getSyncRequest(); window.Whisper.events.trigger('contactsync:begin'); syncRequest.addEventListener('success', () => { window.log.info('sync successful'); @@ -1969,7 +1979,7 @@ export async function startApp(): Promise { const { wrap, sendOptions, - } = window.ConversationController.prepareForSend(ourId, { + } = await window.ConversationController.prepareForSend(ourId, { syncMessage: true, }); @@ -2090,17 +2100,6 @@ export async function startApp(): Promise { newVersion ); - [SenderCertificateMode.WithE164, SenderCertificateMode.WithoutE164].forEach( - mode => { - refreshSenderCertificate.initialize({ - events: window.Whisper.events, - storage: window.storage, - mode, - navigator, - }); - } - ); - window.Whisper.deliveryReceiptQueue.start(); window.Whisper.Notifications.enable(); diff --git a/ts/groups.ts b/ts/groups.ts index 6f973ad51a28..efd62ff80fc5 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -1241,7 +1241,7 @@ export async function modifyGroupV2({ ? window.storage.get('profileKey') : undefined; - const sendOptions = conversation.getSendOptions(); + const sendOptions = await conversation.getSendOptions(); const timestamp = Date.now(); const promise = conversation.wrapSend( diff --git a/ts/metadata/SecretSessionCipher.ts b/ts/metadata/SecretSessionCipher.ts index 89c5e7abde75..2dd57f51eb66 100644 --- a/ts/metadata/SecretSessionCipher.ts +++ b/ts/metadata/SecretSessionCipher.ts @@ -3,6 +3,7 @@ /* eslint-disable class-methods-use-this */ +import * as z from 'zod'; import * as CiphertextMessage from './CiphertextMessage'; import { bytesFromString, @@ -44,9 +45,16 @@ export const enum SenderCertificateMode { WithoutE164, } -export type SerializedCertificateType = { - serialized: ArrayBuffer; -}; +export const serializedCertificateSchema = z + .object({ + expires: z.number().optional(), + serialized: z.instanceof(ArrayBuffer), + }) + .nonstrict(); + +export type SerializedCertificateType = z.infer< + typeof serializedCertificateSchema +>; type ServerCertificateType = { id: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 8820810de984..15c93ebf5ebf 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -54,7 +54,11 @@ import { PhoneNumberSharingMode, parsePhoneNumberSharingMode, } from '../util/phoneNumberSharingMode'; -import { SerializedCertificateType } from '../metadata/SecretSessionCipher'; +import { + SenderCertificateMode, + SerializedCertificateType, +} from '../metadata/SecretSessionCipher'; +import { senderCertificateService } from '../services/senderCertificate'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -1090,7 +1094,7 @@ export class ConversationModel extends window.Backbone.Model< return undefined; } - sendTypingMessage(isTyping: boolean): void { + async sendTypingMessage(isTyping: boolean): Promise { if (!window.textsecure.messaging) { return; } @@ -1109,7 +1113,7 @@ export class ConversationModel extends window.Backbone.Model< return; } - const sendOptions = this.getSendOptions(); + const sendOptions = await this.getSendOptions(); this.wrapSend( window.textsecure.messaging.sendTypingMessage( { @@ -1912,7 +1916,10 @@ export class ConversationModel extends window.Backbone.Model< await this.applyMessageRequestResponse(response); const { ourNumber, ourUuid } = this; - const { wrap, sendOptions } = window.ConversationController.prepareForSend( + const { + wrap, + sendOptions, + } = await window.ConversationController.prepareForSend( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ourNumber || ourUuid!, { @@ -2105,12 +2112,12 @@ export class ConversationModel extends window.Backbone.Model< // Because syncVerification sends a (null) message to the target of the verify and // a sync message to our own devices, we need to send the accessKeys down for both // contacts. So we merge their sendOptions. - const { sendOptions } = window.ConversationController.prepareForSend( + const { sendOptions } = await window.ConversationController.prepareForSend( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.ourNumber || this.ourUuid!, { syncMessage: true } ); - const contactSendOptions = this.getSendOptions(); + const contactSendOptions = await this.getSendOptions(); const options = { ...sendOptions, ...contactSendOptions }; const promise = window.textsecure.storage.protocol.loadIdentityKey(e164); @@ -3107,7 +3114,7 @@ export class ConversationModel extends window.Backbone.Model< throw new Error('Cannot send DOE while offline!'); } - const options = this.getSendOptions(); + const options = await this.getSendOptions(); const promise = (() => { if (this.isPrivate()) { @@ -3239,7 +3246,7 @@ export class ConversationModel extends window.Backbone.Model< return message.sendSyncMessageOnly(dataMessage); } - const options = this.getSendOptions(); + const options = await this.getSendOptions(); const promise = (() => { if (this.isPrivate()) { @@ -3302,7 +3309,7 @@ export class ConversationModel extends window.Backbone.Model< await window.textsecure.messaging.sendProfileKeyUpdate( profileKey, recipients, - this.getSendOptions(), + await this.getSendOptions(), this.get('groupId') ); } @@ -3435,7 +3442,7 @@ export class ConversationModel extends window.Backbone.Model< } const conversationType = this.get('type'); - const options = this.getSendOptions(); + const options = await this.getSendOptions(); let promise; if (conversationType === Message.GROUP) { @@ -3555,17 +3562,17 @@ export class ConversationModel extends window.Backbone.Model< ); } - getSendOptions(options = {}): SendOptionsType { - const sendMetadata = this.getSendMetadata(options); + async getSendOptions(options = {}): Promise { + const sendMetadata = await this.getSendMetadata(options); return { sendMetadata, }; } - getSendMetadata( + async getSendMetadata( options: { syncMessage?: string; disableMeCheck?: boolean } = {} - ): SendMetadataType | undefined { + ): Promise { const { syncMessage, disableMeCheck } = options; // START: this code has an Expiration date of ~2018/11/21 @@ -3580,11 +3587,19 @@ export class ConversationModel extends window.Backbone.Model< // END if (!this.isPrivate()) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const infoArray = this.contactCollection!.map(conversation => - conversation.getSendMetadata(options) + assert( + this.contactCollection, + 'getSendMetadata: expected contactCollection to be defined' ); - return Object.assign({}, ...infoArray); + const result: SendMetadataType = {}; + await Promise.all( + this.contactCollection.map(async conversation => { + const sendMetadata = + (await conversation.getSendMetadata(options)) || {}; + Object.assign(result, sendMetadata); + }) + ); + return result; } const accessKey = this.get('accessKey'); @@ -3598,7 +3613,7 @@ export class ConversationModel extends window.Backbone.Model< const e164 = this.get('e164'); const uuid = this.get('uuid'); - const senderCertificate = this.getSenderCertificateForDirectConversation(); + const senderCertificate = await this.getSenderCertificateForDirectConversation(); // If we've never fetched user's profile, we default to what we have if (sealedSender === SEALED_SENDER.UNKNOWN) { @@ -3630,9 +3645,9 @@ export class ConversationModel extends window.Backbone.Model< }; } - private getSenderCertificateForDirectConversation(): - | undefined - | SerializedCertificateType { + private getSenderCertificateForDirectConversation(): Promise< + undefined | SerializedCertificateType + > { if (!this.isPrivate()) { throw new Error( 'getSenderCertificateForDirectConversation should only be called for direct conversations' @@ -3643,33 +3658,26 @@ export class ConversationModel extends window.Backbone.Model< window.storage.get('phoneNumberSharingMode') ); - let storageKey: 'senderCertificate' | 'senderCertificateNoE164'; + let certificateMode: SenderCertificateMode; switch (phoneNumberSharingMode) { case PhoneNumberSharingMode.Everybody: - storageKey = 'senderCertificate'; + certificateMode = SenderCertificateMode.WithE164; break; case PhoneNumberSharingMode.ContactsOnly: { const isInSystemContacts = Boolean(this.get('name')); - storageKey = isInSystemContacts - ? 'senderCertificate' - : 'senderCertificateNoE164'; + certificateMode = isInSystemContacts + ? SenderCertificateMode.WithE164 + : SenderCertificateMode.WithoutE164; break; } case PhoneNumberSharingMode.Nobody: - storageKey = 'senderCertificateNoE164'; + certificateMode = SenderCertificateMode.WithoutE164; break; default: throw missingCaseError(phoneNumberSharingMode); } - const result = window.storage.get(storageKey); - assert( - result, - `getSenderCertificateForDirectConversation: couldn't find a certificate stored in ${JSON.stringify( - storageKey - )}. Returning undefined` - ); - return result; + return senderCertificateService.get(certificateMode); } // Is this someone who is a contact, or are we sharing our profile with them? @@ -4055,7 +4063,7 @@ export class ConversationModel extends window.Backbone.Model< if (this.get('profileSharing')) { profileKey = window.storage.get('profileKey'); } - const sendOptions = this.getSendOptions(); + const sendOptions = await this.getSendOptions(); let promise; if (this.isMe()) { @@ -4174,7 +4182,7 @@ export class ConversationModel extends window.Backbone.Model< const message = window.MessageController.register(model.id, model); this.addSingleMessage(message); - const options = this.getSendOptions(); + const options = await this.getSendOptions(); message.send( this.wrapSend( // TODO: DESKTOP-724 @@ -4227,7 +4235,7 @@ export class ConversationModel extends window.Backbone.Model< const message = window.MessageController.register(model.id, model); this.addSingleMessage(message); - const options = this.getSendOptions(); + const options = await this.getSendOptions(); message.send( this.wrapSend( window.textsecure.messaging.leaveGroup( @@ -4299,7 +4307,7 @@ export class ConversationModel extends window.Backbone.Model< // to a contact, we need accessKeys for both. const { sendOptions, - } = window.ConversationController.prepareForSend( + } = await window.ConversationController.prepareForSend( window.ConversationController.getOurConversationId(), { syncMessage: true } ); @@ -4314,7 +4322,7 @@ export class ConversationModel extends window.Backbone.Model< // Only send read receipts for accepted conversations if (window.storage.get('read-receipt-setting') && this.getAccepted()) { window.log.info(`Sending ${items.length} read receipts`); - const convoSendOptions = this.getSendOptions(); + const convoSendOptions = await this.getSendOptions(); const receiptsBySender = window._.groupBy(items, 'senderId'); await Promise.all( @@ -4452,7 +4460,8 @@ export class ConversationModel extends window.Backbone.Model< )); } - const sendMetadata = c.getSendMetadata({ disableMeCheck: true }) || {}; + const sendMetadata = + (await c.getSendMetadata({ disableMeCheck: true })) || {}; const getInfo = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {}; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 895350d8a848..f167ab73646a 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1703,9 +1703,12 @@ export class MessageModel extends window.Backbone.Model { const { wrap, sendOptions, - } = window.ConversationController.prepareForSend(ourNumber || ourUuid, { - syncMessage: true, - }); + } = await window.ConversationController.prepareForSend( + ourNumber || ourUuid, + { + syncMessage: true, + } + ); await wrap( window.textsecure.messaging.syncViewOnceOpen( @@ -2119,7 +2122,7 @@ export class MessageModel extends window.Backbone.Model { } let promise; - const options = conversation.getSendOptions(); + const options = await conversation.getSendOptions(); if (conversation.isPrivate()) { const [identifier] = recipients; @@ -2312,9 +2315,10 @@ export class MessageModel extends window.Backbone.Model { return this.sendSyncMessageOnly(dataMessage); } - const { wrap, sendOptions } = window.ConversationController.prepareForSend( - identifier - ); + const { + wrap, + sendOptions, + } = await window.ConversationController.prepareForSend(identifier); const promise = window.textsecure.messaging.sendMessageToIdentifier( identifier, body, @@ -2533,7 +2537,10 @@ export class MessageModel extends window.Backbone.Model { async sendSyncMessage(): Promise { const ourNumber = window.textsecure.storage.user.getNumber(); const ourUuid = window.textsecure.storage.user.getUuid(); - const { wrap, sendOptions } = window.ConversationController.prepareForSend( + const { + wrap, + sendOptions, + } = await window.ConversationController.prepareForSend( ourUuid || ourNumber, { syncMessage: true, diff --git a/ts/refreshSenderCertificate.ts b/ts/refreshSenderCertificate.ts deleted file mode 100644 index 51eed0dea389..000000000000 --- a/ts/refreshSenderCertificate.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright 2018-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { once } from 'lodash'; -import * as log from './logging/log'; -import { missingCaseError } from './util/missingCaseError'; -import { SenderCertificateMode } from './metadata/SecretSessionCipher'; - -const ONE_DAY = 24 * 60 * 60 * 1000; // one day -const MINIMUM_TIME_LEFT = 2 * 60 * 60 * 1000; // two hours - -let timeout: null | ReturnType = null; -let scheduledTime: null | number = null; - -const removeOldKey = once((storage: typeof window.storage) => { - const oldCertKey = 'senderCertificateWithUuid'; - const oldUuidCert = storage.get(oldCertKey); - if (oldUuidCert) { - storage.remove(oldCertKey); - } -}); - -// We need to refresh our own profile regularly to account for newly-added devices which -// do not support unidentified delivery. -function refreshOurProfile() { - window.log.info('refreshOurProfile'); - const ourId = window.ConversationController.getOurConversationIdOrThrow(); - const conversation = window.ConversationController.get(ourId); - conversation?.getProfiles(); -} - -export function initialize({ - events, - storage, - mode, - navigator, -}: Readonly<{ - events: { - on: (name: string, callback: () => void) => void; - }; - storage: typeof window.storage; - mode: SenderCertificateMode; - navigator: Navigator; -}>): void { - let storageKey: 'senderCertificate' | 'senderCertificateNoE164'; - let logString: string; - switch (mode) { - case SenderCertificateMode.WithE164: - storageKey = 'senderCertificate'; - logString = 'sender certificate WITH E164'; - break; - case SenderCertificateMode.WithoutE164: - storageKey = 'senderCertificateNoE164'; - logString = 'sender certificate WITHOUT E164'; - break; - default: - throw missingCaseError(mode); - } - - runWhenOnline(); - removeOldKey(storage); - - events.on('timetravel', scheduleNextRotation); - - function scheduleNextRotation() { - const now = Date.now(); - const certificate = storage.get(storageKey); - if (!certificate || !certificate.expires) { - setTimeoutForNextRun(scheduledTime || now); - - return; - } - - // If we have a time in place and it's already before the safety zone before expire, - // we keep it - if ( - scheduledTime && - scheduledTime <= certificate.expires - MINIMUM_TIME_LEFT - ) { - setTimeoutForNextRun(scheduledTime); - return; - } - - // Otherwise, we reset every day, or earlier if the safety zone requires it - const time = Math.min( - now + ONE_DAY, - certificate.expires - MINIMUM_TIME_LEFT - ); - setTimeoutForNextRun(time); - } - - async function saveCert(certificate: string): Promise { - const arrayBuffer = window.Signal.Crypto.base64ToArrayBuffer(certificate); - const decodedContainer = window.textsecure.protobuf.SenderCertificate.decode( - arrayBuffer - ); - const decodedCert = window.textsecure.protobuf.SenderCertificate.Certificate.decode( - decodedContainer.certificate - ); - - // We don't want to send a protobuf-generated object across IPC, so we make - // our own object. - const toSave = { - expires: decodedCert.expires.toNumber(), - serialized: arrayBuffer, - }; - await storage.put(storageKey, toSave); - } - - async function run(): Promise { - log.info(`refreshSenderCertificate: Getting new ${logString}...`); - try { - const OLD_USERNAME = storage.get('number_id'); - const USERNAME = storage.get('uuid_id'); - const PASSWORD = storage.get('password'); - const server = window.WebAPI.connect({ - username: USERNAME || OLD_USERNAME, - password: PASSWORD, - }); - - const omitE164 = mode === SenderCertificateMode.WithoutE164; - const { certificate } = await server.getSenderCertificate(omitE164); - - await saveCert(certificate); - - scheduledTime = null; - scheduleNextRotation(); - } catch (error) { - log.error( - `refreshSenderCertificate: Get failed for ${logString}. Trying again in five minutes...`, - error && error.stack ? error.stack : error - ); - - scheduledTime = Date.now() + 5 * 60 * 1000; - - scheduleNextRotation(); - } - - refreshOurProfile(); - } - - function runWhenOnline() { - if (navigator.onLine) { - run(); - } else { - log.info( - 'refreshSenderCertificate: Offline. Will update certificate when online...' - ); - const listener = () => { - log.info( - 'refreshSenderCertificate: Online. Now updating certificate...' - ); - window.removeEventListener('online', listener); - run(); - }; - window.addEventListener('online', listener); - } - } - - function setTimeoutForNextRun(time = Date.now()) { - const now = Date.now(); - - if (scheduledTime !== time || !timeout) { - log.info( - `refreshSenderCertificate: Next ${logString} refresh scheduled for`, - new Date(time).toISOString() - ); - } - - scheduledTime = time; - const waitTime = Math.max(0, time - now); - - if (timeout) { - clearTimeout(timeout); - } - timeout = setTimeout(runWhenOnline, waitTime); - } -} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 70c4d867162a..da45e35a51ad 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -730,10 +730,10 @@ export class CallingClass { }); } - private sendGroupCallUpdateMessage( + private async sendGroupCallUpdateMessage( conversationId: string, eraId: string - ): void { + ): Promise { const conversation = window.ConversationController.get(conversationId); if (!conversation) { window.log.error( @@ -743,7 +743,7 @@ export class CallingClass { } const groupV2 = conversation.getGroupV2Info(); - const sendOptions = conversation.getSendOptions(); + const sendOptions = await conversation.getSendOptions(); if (!groupV2) { window.log.error( 'Unable to send group call update message for conversation that lacks groupV2 info' @@ -1258,7 +1258,7 @@ export class CallingClass { ): Promise { const conversation = window.ConversationController.get(remoteUserId); const sendOptions = conversation - ? conversation.getSendOptions() + ? await conversation.getSendOptions() : undefined; if (!window.textsecure.messaging) { diff --git a/ts/services/senderCertificate.ts b/ts/services/senderCertificate.ts new file mode 100644 index 000000000000..4d6e046eb2dc --- /dev/null +++ b/ts/services/senderCertificate.ts @@ -0,0 +1,253 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + SenderCertificateMode, + serializedCertificateSchema, + SerializedCertificateType, +} from '../metadata/SecretSessionCipher'; +import { SenderCertificateClass } from '../textsecure'; +import { base64ToArrayBuffer } from '../Crypto'; +import { assert } from '../util/assert'; +import { missingCaseError } from '../util/missingCaseError'; +import { waitForOnline } from '../util/waitForOnline'; +import * as log from '../logging/log'; + +// We define a stricter storage here that returns `unknown` instead of `any`. +type Storage = { + get(key: string): unknown; + put(key: string, value: unknown): Promise; + remove(key: string): Promise; +}; + +// In case your clock is different from the server's, we "fake" expire certificates early. +const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000; + +// This is exported for testing. +export class SenderCertificateService { + private WebAPI?: typeof window.WebAPI; + + private SenderCertificate?: typeof SenderCertificateClass; + + private fetchPromises: Map< + SenderCertificateMode, + Promise + > = new Map(); + + private navigator?: { onLine: boolean }; + + private onlineEventTarget?: EventTarget; + + private storage?: Storage; + + initialize({ + SenderCertificate, + WebAPI, + navigator, + onlineEventTarget, + storage, + }: { + WebAPI: typeof window.WebAPI; + navigator: Readonly<{ onLine: boolean }>; + onlineEventTarget: EventTarget; + SenderCertificate: typeof SenderCertificateClass; + storage: Storage; + }): void { + log.info('Sender certificate service initialized'); + + this.SenderCertificate = SenderCertificate; + this.WebAPI = WebAPI; + this.navigator = navigator; + this.onlineEventTarget = onlineEventTarget; + this.storage = storage; + + removeOldKey(storage); + } + + async get( + mode: SenderCertificateMode + ): Promise { + const storedCertificate = this.getStoredCertificate(mode); + if (storedCertificate) { + log.info( + `Sender certificate service found a valid ${modeToLogString( + mode + )} certificate in storage; skipping fetch` + ); + return storedCertificate; + } + + return this.fetchCertificate(mode); + } + + private getStoredCertificate( + mode: SenderCertificateMode + ): undefined | SerializedCertificateType { + const { storage } = this; + assert( + storage, + 'Sender certificate service method was called before it was initialized' + ); + + const valueInStorage = storage.get(modeToStorageKey(mode)); + return serializedCertificateSchema.check(valueInStorage) && + isExpirationValid(valueInStorage.expires) + ? valueInStorage + : undefined; + } + + private fetchCertificate( + mode: SenderCertificateMode + ): Promise { + // This prevents multiple concurrent fetches. + const existingPromise = this.fetchPromises.get(mode); + if (existingPromise) { + log.info( + `Sender certificate service was already fetching a ${modeToLogString( + mode + )} certificate; piggybacking off of that` + ); + return existingPromise; + } + + let promise: Promise; + const doFetch = async () => { + const result = await this.fetchAndSaveCertificate(mode); + assert( + this.fetchPromises.get(mode) === promise, + 'Sender certificate service was deleting a different promise than expected' + ); + this.fetchPromises.delete(mode); + return result; + }; + promise = doFetch(); + + assert( + !this.fetchPromises.has(mode), + 'Sender certificate service somehow already had a promise for this mode' + ); + this.fetchPromises.set(mode, promise); + return promise; + } + + private async fetchAndSaveCertificate( + mode: SenderCertificateMode + ): Promise { + const { SenderCertificate, storage, navigator, onlineEventTarget } = this; + assert( + SenderCertificate && storage && navigator && onlineEventTarget, + 'Sender certificate service method was called before it was initialized' + ); + + log.info( + `Sender certificate service: fetching and saving a ${modeToLogString( + mode + )} certificate` + ); + + await waitForOnline(navigator, onlineEventTarget); + + let certificateString: string; + try { + certificateString = await this.requestSenderCertificate(mode); + } catch (err) { + log.warn( + `Sender certificate service could not fetch a ${modeToLogString( + mode + )} certificate. Returning undefined`, + err && err.stack ? err.stack : err + ); + return undefined; + } + const certificate = base64ToArrayBuffer(certificateString); + const decodedContainer = SenderCertificate.decode(certificate); + const decodedCert = decodedContainer.certificate + ? SenderCertificate.Certificate.decode(decodedContainer.certificate) + : undefined; + const expires = decodedCert?.expires?.toNumber(); + + if (!isExpirationValid(expires)) { + log.warn( + `Sender certificate service fetched a ${modeToLogString( + mode + )} certificate from the server that was already expired (or was invalid). Is your system clock off?` + ); + return undefined; + } + + const serializedCertificate = { + expires: expires - CLOCK_SKEW_THRESHOLD, + serialized: certificate, + }; + + await storage.put(modeToStorageKey(mode), serializedCertificate); + + return serializedCertificate; + } + + private async requestSenderCertificate( + mode: SenderCertificateMode + ): Promise { + const { storage, WebAPI } = this; + assert( + storage && WebAPI, + 'Sender certificate service method was called before it was initialized' + ); + + const username = storage.get('uuid_id') || storage.get('number_id'); + const password = storage.get('password'); + if (typeof username !== 'string') { + throw new Error( + 'Sender certificate service: username in storage was not a string. Cannot connect' + ); + } + if (typeof password !== 'string') { + throw new Error( + 'Sender certificate service: password in storage was not a string. Cannot connect' + ); + } + + const server = WebAPI.connect({ username, password }); + const omitE164 = mode === SenderCertificateMode.WithoutE164; + const { certificate } = await server.getSenderCertificate(omitE164); + return certificate; + } +} + +function modeToStorageKey( + mode: SenderCertificateMode +): 'senderCertificate' | 'senderCertificateNoE164' { + switch (mode) { + case SenderCertificateMode.WithE164: + return 'senderCertificate'; + case SenderCertificateMode.WithoutE164: + return 'senderCertificateNoE164'; + default: + throw missingCaseError(mode); + } +} + +function modeToLogString(mode: SenderCertificateMode): string { + switch (mode) { + case SenderCertificateMode.WithE164: + return 'yes-E164'; + case SenderCertificateMode.WithoutE164: + return 'no-E164'; + default: + throw missingCaseError(mode); + } +} + +function isExpirationValid(expiration: unknown): expiration is number { + return typeof expiration === 'number' && expiration > Date.now(); +} + +function removeOldKey(storage: Readonly) { + const oldCertKey = 'senderCertificateWithUuid'; + const oldUuidCert = storage.get(oldCertKey); + if (oldUuidCert) { + storage.remove(oldCertKey); + } +} + +export const senderCertificateService = new SenderCertificateService(); diff --git a/ts/shims/textsecure.ts b/ts/shims/textsecure.ts index 68cc266bf8d3..711df2ed2471 100644 --- a/ts/shims/textsecure.ts +++ b/ts/shims/textsecure.ts @@ -1,16 +1,18 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -export function sendStickerPackSync( +export async function sendStickerPackSync( packId: string, packKey: string, installed: boolean -): void { +): Promise { const { ConversationController, textsecure, log } = window; const ourNumber = textsecure.storage.user.getNumber(); - const { wrap, sendOptions } = ConversationController.prepareForSend( + const { wrap, sendOptions } = await ConversationController.prepareForSend( ourNumber, - { syncMessage: true } + { + syncMessage: true, + } ); if (!textsecure.messaging) { diff --git a/ts/test-electron/services/senderCertificate_test.ts b/ts/test-electron/services/senderCertificate_test.ts new file mode 100644 index 000000000000..9f959f322eac --- /dev/null +++ b/ts/test-electron/services/senderCertificate_test.ts @@ -0,0 +1,257 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// We allow `any`s because it's arduous to set up "real" WebAPIs and storages. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { v4 as uuid } from 'uuid'; +import { arrayBufferToBase64 } from '../../Crypto'; +import { SenderCertificateClass } from '../../textsecure'; +import { SenderCertificateMode } from '../../metadata/SecretSessionCipher'; + +import { SenderCertificateService } from '../../services/senderCertificate'; + +describe('SenderCertificateService', () => { + const FIFTEEN_MINUTES = 15 * 60 * 1000; + + let fakeValidCertificate: SenderCertificateClass; + let fakeValidCertificateExpiry: number; + let fakeServer: any; + let fakeWebApi: typeof window.WebAPI; + let fakeNavigator: { onLine: boolean }; + let fakeWindow: EventTarget; + let fakeStorage: any; + let SenderCertificate: typeof SenderCertificateClass; + + function initializeTestService(): SenderCertificateService { + const result = new SenderCertificateService(); + result.initialize({ + SenderCertificate, + WebAPI: fakeWebApi, + navigator: fakeNavigator, + onlineEventTarget: fakeWindow, + storage: fakeStorage, + }); + return result; + } + + before(done => { + const protoPath = path.join( + __dirname, + '..', + '..', + '..', + 'protos', + 'UnidentifiedDelivery.proto' + ); + fs.readFile(protoPath, 'utf8', (err, proto) => { + if (err) { + done(err); + return; + } + ({ SenderCertificate } = global.window.dcodeIO.ProtoBuf.loadProto( + proto + ).build('signalservice')); + done(); + }); + }); + + beforeEach(() => { + fakeValidCertificate = new SenderCertificate(); + fakeValidCertificateExpiry = Date.now() + 604800000; + const certificate = new SenderCertificate.Certificate(); + certificate.expires = global.window.dcodeIO.Long.fromNumber( + fakeValidCertificateExpiry + ); + fakeValidCertificate.certificate = certificate.toArrayBuffer(); + + fakeServer = { + getSenderCertificate: sinon.stub().resolves({ + certificate: arrayBufferToBase64(fakeValidCertificate.toArrayBuffer()), + }), + }; + fakeWebApi = { connect: sinon.stub().returns(fakeServer) }; + + fakeNavigator = { onLine: true }; + + fakeWindow = { + addEventListener: sinon.stub(), + dispatchEvent: sinon.stub(), + removeEventListener: sinon.stub(), + }; + + fakeStorage = { + get: sinon.stub(), + put: sinon.stub().resolves(), + remove: sinon.stub().resolves(), + }; + fakeStorage.get.withArgs('uuid_id').returns(`${uuid()}.2`); + fakeStorage.get.withArgs('password').returns('abc123'); + }); + + describe('initialize', () => { + it('removes an old storage service key if it was present', () => { + fakeStorage.get + .withArgs('senderCertificateWithUuid') + .returns('some value'); + + initializeTestService(); + + sinon.assert.calledWith(fakeStorage.remove, 'senderCertificateWithUuid'); + }); + + it("doesn't remove anything from storage if it wasn't there", () => { + initializeTestService(); + + sinon.assert.notCalled(fakeStorage.put); + }); + }); + + describe('get', () => { + it('returns valid yes-E164 certificates from storage if they exist', async () => { + const cert = { + expires: Date.now() + 123456, + serialized: new ArrayBuffer(2), + }; + fakeStorage.get.withArgs('senderCertificate').returns(cert); + + const service = initializeTestService(); + + assert.strictEqual( + await service.get(SenderCertificateMode.WithE164), + cert + ); + + sinon.assert.notCalled(fakeStorage.put); + }); + + it('returns valid no-E164 certificates from storage if they exist', async () => { + const cert = { + expires: Date.now() + 123456, + serialized: new ArrayBuffer(2), + }; + fakeStorage.get.withArgs('senderCertificateNoE164').returns(cert); + + const service = initializeTestService(); + + assert.strictEqual( + await service.get(SenderCertificateMode.WithoutE164), + cert + ); + + sinon.assert.notCalled(fakeStorage.put); + }); + + it('returns and stores a newly-fetched yes-E164 certificate if none was in storage', async () => { + const service = initializeTestService(); + + assert.deepEqual(await service.get(SenderCertificateMode.WithE164), { + expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES, + serialized: fakeValidCertificate.toArrayBuffer(), + }); + + sinon.assert.calledWithMatch(fakeStorage.put, 'senderCertificate', { + expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES, + serialized: fakeValidCertificate.toArrayBuffer(), + }); + + sinon.assert.calledWith(fakeServer.getSenderCertificate, false); + }); + + it('returns and stores a newly-fetched no-E164 certificate if none was in storage', async () => { + const service = initializeTestService(); + + assert.deepEqual(await service.get(SenderCertificateMode.WithoutE164), { + expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES, + serialized: fakeValidCertificate.toArrayBuffer(), + }); + + sinon.assert.calledWithMatch(fakeStorage.put, 'senderCertificateNoE164', { + expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES, + serialized: fakeValidCertificate.toArrayBuffer(), + }); + + sinon.assert.calledWith(fakeServer.getSenderCertificate, true); + }); + + it('fetches new certificates if the value in storage has already expired', async () => { + const service = initializeTestService(); + + fakeStorage.get.withArgs('senderCertificate').returns({ + expires: Date.now() - 1000, + serialized: new ArrayBuffer(2), + }); + + await service.get(SenderCertificateMode.WithE164); + + sinon.assert.called(fakeServer.getSenderCertificate); + }); + + it('fetches new certificates if the value in storage is invalid', async () => { + const service = initializeTestService(); + + fakeStorage.get.withArgs('senderCertificate').returns({ + serialized: 'not an arraybuffer', + }); + + await service.get(SenderCertificateMode.WithE164); + + sinon.assert.called(fakeServer.getSenderCertificate); + }); + + it('only hits the server once per certificate type when requesting many times', async () => { + const service = initializeTestService(); + + await Promise.all([ + service.get(SenderCertificateMode.WithE164), + service.get(SenderCertificateMode.WithoutE164), + service.get(SenderCertificateMode.WithE164), + service.get(SenderCertificateMode.WithoutE164), + service.get(SenderCertificateMode.WithE164), + service.get(SenderCertificateMode.WithoutE164), + service.get(SenderCertificateMode.WithE164), + service.get(SenderCertificateMode.WithoutE164), + ]); + + sinon.assert.calledTwice(fakeServer.getSenderCertificate); + }); + + it('hits the server again after a request has completed', async () => { + const service = initializeTestService(); + + await service.get(SenderCertificateMode.WithE164); + sinon.assert.calledOnce(fakeServer.getSenderCertificate); + await service.get(SenderCertificateMode.WithE164); + + sinon.assert.calledTwice(fakeServer.getSenderCertificate); + }); + + it('returns undefined if the request to the server fails', async () => { + const service = initializeTestService(); + + fakeServer.getSenderCertificate.rejects(new Error('uh oh')); + + assert.isUndefined(await service.get(SenderCertificateMode.WithE164)); + }); + + it('returns undefined if the server returns an already-expired certificate', async () => { + const service = initializeTestService(); + + const expiredCertificate = new SenderCertificate(); + const certificate = new SenderCertificate.Certificate(); + certificate.expires = global.window.dcodeIO.Long.fromNumber( + Date.now() - 1000 + ); + expiredCertificate.certificate = certificate.toArrayBuffer(); + fakeServer.getSenderCertificate.resolves({ + certificate: arrayBufferToBase64(expiredCertificate.toArrayBuffer()), + }); + + assert.isUndefined(await service.get(SenderCertificateMode.WithE164)); + }); + }); +}); diff --git a/ts/test-electron/util/waitForOnline_test.ts b/ts/test-electron/util/waitForOnline_test.ts new file mode 100644 index 000000000000..c71ad3922cb3 --- /dev/null +++ b/ts/test-electron/util/waitForOnline_test.ts @@ -0,0 +1,51 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; + +import { waitForOnline } from '../../util/waitForOnline'; + +describe('waitForOnline', () => { + function getFakeWindow(): EventTarget { + const result = new EventTarget(); + sinon.stub(result, 'addEventListener'); + sinon.stub(result, 'removeEventListener'); + return result; + } + + it("resolves immediately if you're online", async () => { + const fakeNavigator = { onLine: true }; + const fakeWindow = getFakeWindow(); + + await waitForOnline(fakeNavigator, fakeWindow); + + sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); + sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); + }); + + it("if you're offline, resolves as soon as you're online", async () => { + const fakeNavigator = { onLine: false }; + const fakeWindow = getFakeWindow(); + + (fakeWindow.addEventListener as sinon.SinonStub) + .withArgs('online') + .callsFake((_eventName: string, callback: () => void) => { + setTimeout(callback, 0); + }); + + let done = false; + const promise = (async () => { + await waitForOnline(fakeNavigator, fakeWindow); + done = true; + })(); + + assert.isFalse(done); + + await promise; + + assert.isTrue(done); + sinon.assert.calledOnce(fakeWindow.addEventListener as sinon.SinonStub); + sinon.assert.calledOnce(fakeWindow.removeEventListener as sinon.SinonStub); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index b8840fe8072e..c11f52313355 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -10,6 +10,7 @@ import { import Crypto from './textsecure/Crypto'; import MessageReceiver from './textsecure/MessageReceiver'; import MessageSender from './textsecure/SendMessage'; +import SyncRequest from './textsecure/SyncRequest'; import EventTarget from './textsecure/EventTarget'; import { ByteBufferClass } from './window.d'; import SendMessage, { SendOptionsType } from './textsecure/SendMessage'; @@ -90,7 +91,7 @@ export type TextSecureType = { MessageReceiver: typeof MessageReceiver; AccountManager: WhatIsThis; MessageSender: WhatIsThis; - SyncRequest: WhatIsThis; + SyncRequest: typeof SyncRequest; }; type StoredSignedPreKeyType = SignedPreKeyType & { diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 96f02adc13fd..7fbd16f11188 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1255,7 +1255,7 @@ class MessageReceiverInner extends EventTarget { await sessionCipher.closeOpenSessionForDevice(); // Send a null message with newly-created session - const sendOptions = conversation.getSendOptions(); + const sendOptions = await conversation.getSendOptions(); await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions); } diff --git a/ts/textsecure/SyncRequest.ts b/ts/textsecure/SyncRequest.ts index 2f8a8835480e..1494686f86cd 100644 --- a/ts/textsecure/SyncRequest.ts +++ b/ts/textsecure/SyncRequest.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable more/no-then */ @@ -9,9 +9,10 @@ import EventTarget from './EventTarget'; import MessageReceiver from './MessageReceiver'; import MessageSender from './SendMessage'; +import { assert } from '../util/assert'; class SyncRequestInner extends EventTarget { - receiver: MessageReceiver; + private started = false; contactSync?: boolean; @@ -23,7 +24,10 @@ class SyncRequestInner extends EventTarget { ongroup: Function; - constructor(sender: MessageSender, receiver: MessageReceiver) { + constructor( + private sender: MessageSender, + private receiver: MessageReceiver + ) { super(); if ( @@ -34,21 +38,30 @@ class SyncRequestInner extends EventTarget { 'Tried to construct a SyncRequest without MessageSender and MessageReceiver' ); } - this.receiver = receiver; this.oncontact = this.onContactSyncComplete.bind(this); receiver.addEventListener('contactsync', this.oncontact); this.ongroup = this.onGroupSyncComplete.bind(this); receiver.addEventListener('groupsync', this.ongroup); + } + + async start(): Promise { + if (this.started) { + assert(false, 'SyncRequestInner: started more than once. Doing nothing'); + return; + } + this.started = true; + + const { sender } = this; const ourNumber = window.textsecure.storage.user.getNumber(); - const { wrap, sendOptions } = window.ConversationController.prepareForSend( - ourNumber, - { - syncMessage: true, - } - ); + const { + wrap, + sendOptions, + } = await window.ConversationController.prepareForSend(ourNumber, { + syncMessage: true, + }); window.log.info('SyncRequest created. Sending config sync request...'); wrap(sender.sendRequestConfigurationSyncMessage(sendOptions)); @@ -106,13 +119,20 @@ class SyncRequestInner extends EventTarget { } export default class SyncRequest { - constructor(sender: MessageSender, receiver: MessageReceiver) { - const inner = new SyncRequestInner(sender, receiver); - this.addEventListener = inner.addEventListener.bind(inner); - this.removeEventListener = inner.removeEventListener.bind(inner); - } + private inner: SyncRequestInner; addEventListener: (name: string, handler: Function) => void; removeEventListener: (name: string, handler: Function) => void; + + constructor(sender: MessageSender, receiver: MessageReceiver) { + const inner = new SyncRequestInner(sender, receiver); + this.inner = inner; + this.addEventListener = inner.addEventListener.bind(inner); + this.removeEventListener = inner.removeEventListener.bind(inner); + } + + start(): void { + this.inner.start(); + } } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 39098b47d869..3582298e9f13 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -804,7 +804,9 @@ export type WebAPIType = { } ) => Promise; getProvisioningSocket: () => WebSocket; - getSenderCertificate: (withUuid?: boolean) => Promise; + getSenderCertificate: ( + withUuid?: boolean + ) => Promise<{ certificate: string }>; getSticker: (packId: string, stickerId: number) => Promise; getStickerPackManifest: (packId: string) => Promise; getStorageCredentials: MessageSender['getStorageCredentials']; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 5d425458cac3..521b7e18b9cf 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -17020,7 +17020,7 @@ "rule": "jQuery-wrap(", "path": "ts/shims/textsecure.js", "line": " wrap(textsecure.messaging.sendStickerPackSync([", - "lineNumber": 14, + "lineNumber": 16, "reasonCategory": "falseMatch", "updated": "2020-02-07T19:52:28.522Z" }, @@ -17028,7 +17028,7 @@ "rule": "jQuery-wrap(", "path": "ts/shims/textsecure.ts", "line": " wrap(", - "lineNumber": 24, + "lineNumber": 26, "reasonCategory": "falseMatch", "updated": "2020-02-07T19:52:28.522Z" }, @@ -17100,7 +17100,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.js", "line": " wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));", - "lineNumber": 32, + "lineNumber": 43, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17108,7 +17108,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.js", "line": " wrap(sender.sendRequestBlockSyncMessage(sendOptions));", - "lineNumber": 34, + "lineNumber": 45, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17116,7 +17116,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.js", "line": " wrap(sender.sendRequestContactSyncMessage(sendOptions))", - "lineNumber": 36, + "lineNumber": 47, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17124,7 +17124,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.js", "line": " return wrap(sender.sendRequestGroupSyncMessage(sendOptions));", - "lineNumber": 39, + "lineNumber": 50, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17132,7 +17132,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.ts", "line": " wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));", - "lineNumber": 54, + "lineNumber": 67, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17140,7 +17140,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.ts", "line": " wrap(sender.sendRequestBlockSyncMessage(sendOptions));", - "lineNumber": 57, + "lineNumber": 70, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17148,7 +17148,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.ts", "line": " wrap(sender.sendRequestContactSyncMessage(sendOptions))", - "lineNumber": 60, + "lineNumber": 73, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17156,7 +17156,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.ts", "line": " return wrap(sender.sendRequestGroupSyncMessage(sendOptions));", - "lineNumber": 63, + "lineNumber": 76, "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, @@ -17172,7 +17172,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.ts", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", - "lineNumber": 2232, + "lineNumber": 2234, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" }, @@ -17284,4 +17284,4 @@ "reasonCategory": "falseMatch", "updated": "2021-04-06T23:11:04.431Z" } -] +] \ No newline at end of file diff --git a/ts/util/waitForOnline.ts b/ts/util/waitForOnline.ts new file mode 100644 index 000000000000..3a32802f15b0 --- /dev/null +++ b/ts/util/waitForOnline.ts @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function waitForOnline( + navigator: Readonly<{ onLine: boolean }>, + onlineEventTarget: EventTarget +): Promise { + return new Promise(resolve => { + if (navigator.onLine) { + resolve(); + return; + } + + const listener = () => { + onlineEventTarget.removeEventListener('online', listener); + resolve(); + }; + + onlineEventTarget.addEventListener('online', listener); + }); +} diff --git a/ts/window.d.ts b/ts/window.d.ts index eebfe55a0afb..db13043f6c01 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -70,8 +70,8 @@ import * as searchDuck from './state/ducks/search'; import * as stickersDuck from './state/ducks/stickers'; import * as conversationsSelectors from './state/selectors/conversations'; import * as searchSelectors from './state/selectors/search'; -import { SendOptionsType } from './textsecure/SendMessage'; import AccountManager from './textsecure/AccountManager'; +import { SendOptionsType } from './textsecure/SendMessage'; import Data from './sql/Client'; import { UserMessage } from './types/Message'; import { PhoneNumberFormat } from 'google-libphonenumber'; @@ -97,6 +97,7 @@ import { ElectronLocaleType } from './util/mapToSupportLocale'; import { SignalProtocolStore } from './LibSignalStore'; import { StartupQueue } from './util/StartupQueue'; import * as synchronousCrypto from './util/synchronousCrypto'; +import SyncRequest from './textsecure/SyncRequest'; export { Long } from 'long'; @@ -169,7 +170,7 @@ declare global { getServerPublicParams: () => string; getSfuUrl: () => string; getSocketStatus: () => number; - getSyncRequest: () => WhatIsThis; + getSyncRequest: () => SyncRequest; getTitle: () => string; waitForEmptyEventQueue: () => Promise; getVersion: () => string; @@ -578,6 +579,7 @@ export type DCodeIOType = { fromString: (str: string | null) => Long; isLong: (obj: unknown) => obj is Long; }; + ProtoBuf: WhatIsThis; }; type MessageControllerType = {