Move to websocket for requests to signal server

This commit is contained in:
Fedor Indutny 2021-07-28 14:37:09 -07:00 committed by GitHub
parent 8449f343a6
commit 1c1d0e2da0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1892 additions and 1336 deletions

View file

@ -14,15 +14,9 @@ const fakeAPI = {
getAvatar: fakeCall, getAvatar: fakeCall,
getDevices: fakeCall, getDevices: fakeCall,
// getKeysForIdentifier : fakeCall, // getKeysForIdentifier : fakeCall,
getMessageSocket: async () => ({
on() {},
removeListener() {},
close() {},
sendBytes() {},
}),
getMyKeys: fakeCall, getMyKeys: fakeCall,
getProfile: fakeCall, getProfile: fakeCall,
getProvisioningSocket: fakeCall, getProvisioningResource: fakeCall,
putAttachment: fakeCall, putAttachment: fakeCall,
registerKeys: fakeCall, registerKeys: fakeCall,
requestVerificationSMS: fakeCall, requestVerificationSMS: fakeCall,

View file

@ -1,75 +0,0 @@
// Copyright 2017-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global textsecure */
describe('createTaskWithTimeout', () => {
it('resolves when promise resolves', () => {
const task = () => Promise.resolve('hi!');
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
return taskWithTimeout().then(result => {
assert.strictEqual(result, 'hi!');
});
});
it('flows error from promise back', () => {
const error = new Error('original');
const task = () => Promise.reject(error);
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
return taskWithTimeout().catch(flowedError => {
assert.strictEqual(error, flowedError);
});
});
it('rejects if promise takes too long (this one logs error to console)', () => {
let complete = false;
const task = () =>
new Promise(resolve => {
setTimeout(() => {
complete = true;
resolve();
}, 3000);
});
const taskWithTimeout = textsecure.createTaskWithTimeout(task, this.name, {
timeout: 10,
});
return taskWithTimeout().then(
() => {
throw new Error('it was not supposed to resolve!');
},
() => {
assert.strictEqual(complete, false);
}
);
});
it('resolves if task returns something falsey', () => {
const task = () => {};
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
return taskWithTimeout();
});
it('resolves if task returns a non-promise', () => {
const task = () => 'hi!';
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
return taskWithTimeout().then(result => {
assert.strictEqual(result, 'hi!');
});
});
it('rejects if task throws (and does not log about taking too long)', () => {
const error = new Error('Task is throwing!');
const task = () => {
throw error;
};
const taskWithTimeout = textsecure.createTaskWithTimeout(task, this.name, {
timeout: 10,
});
return taskWithTimeout().then(
() => {
throw new Error('Overall task should reject!');
},
flowedError => {
assert.strictEqual(flowedError, error);
}
);
});
});

View file

@ -107,7 +107,7 @@ try {
let connectStartTime = 0; let connectStartTime = 0;
window.logMessageReceiverConnect = () => { window.logAuthenticatedConnect = () => {
if (connectStartTime === 0) { if (connectStartTime === 0) {
connectStartTime = Date.now(); connectStartTime = Date.now();
} }

View file

@ -135,10 +135,12 @@ export async function startApp(): Promise<void> {
// Initialize WebAPI as early as possible // Initialize WebAPI as early as possible
let server: WebAPIType | undefined; let server: WebAPIType | undefined;
let messageReceiver: MessageReceiver | undefined;
window.storage.onready(() => { window.storage.onready(() => {
server = window.WebAPI.connect( server = window.WebAPI.connect(
window.textsecure.storage.user.getWebAPICredentials() window.textsecure.storage.user.getWebAPICredentials()
); );
window.textsecure.server = server;
window.textsecure.storage.user.on('credentialsChange', async () => { window.textsecure.storage.user.on('credentialsChange', async () => {
strictAssert(server !== undefined, 'WebAPI not ready'); strictAssert(server !== undefined, 'WebAPI not ready');
@ -150,6 +152,122 @@ export async function startApp(): Promise<void> {
initializeAllJobQueues({ initializeAllJobQueues({
server, server,
}); });
window.log.info('Initializing MessageReceiver');
messageReceiver = new MessageReceiver({
server,
storage: window.storage,
serverTrustRoot: window.getServerTrustRoot(),
});
// eslint-disable-next-line no-inner-declarations
function queuedEventListener<Args extends Array<unknown>>(
handler: (...args: Args) => Promise<void> | void,
track = true
): (...args: Args) => void {
return (...args: Args): void => {
eventHandlerQueue.add(async () => {
try {
await handler(...args);
} finally {
// message/sent: Message.handleDataMessage has its own queue and will
// trigger this event itself when complete.
// error: Error processing (below) also has its own queue and self-trigger.
if (track) {
window.Whisper.events.trigger('incrementProgress');
}
}
});
};
}
messageReceiver.addEventListener(
'message',
queuedEventListener(onMessageReceived, false)
);
messageReceiver.addEventListener(
'delivery',
queuedEventListener(onDeliveryReceipt)
);
messageReceiver.addEventListener(
'contact',
queuedEventListener(onContactReceived)
);
messageReceiver.addEventListener(
'contactSync',
queuedEventListener(onContactSyncComplete)
);
messageReceiver.addEventListener(
'group',
queuedEventListener(onGroupReceived)
);
messageReceiver.addEventListener(
'groupSync',
queuedEventListener(onGroupSyncComplete)
);
messageReceiver.addEventListener(
'sent',
queuedEventListener(onSentMessage, false)
);
messageReceiver.addEventListener(
'readSync',
queuedEventListener(onReadSync)
);
messageReceiver.addEventListener(
'read',
queuedEventListener(onReadReceipt)
);
messageReceiver.addEventListener(
'view',
queuedEventListener(onViewReceipt)
);
messageReceiver.addEventListener(
'verified',
queuedEventListener(onVerified)
);
messageReceiver.addEventListener(
'error',
queuedEventListener(onError, false)
);
messageReceiver.addEventListener(
'decryption-error',
queuedEventListener(onDecryptionError)
);
messageReceiver.addEventListener(
'retry-request',
queuedEventListener(onRetryRequest)
);
messageReceiver.addEventListener('empty', queuedEventListener(onEmpty));
messageReceiver.addEventListener(
'reconnect',
queuedEventListener(onReconnect)
);
messageReceiver.addEventListener(
'configuration',
queuedEventListener(onConfiguration)
);
messageReceiver.addEventListener('typing', queuedEventListener(onTyping));
messageReceiver.addEventListener(
'sticker-pack',
queuedEventListener(onStickerPack)
);
messageReceiver.addEventListener(
'viewOnceOpenSync',
queuedEventListener(onViewOnceOpenSync)
);
messageReceiver.addEventListener(
'messageRequestResponse',
queuedEventListener(onMessageRequestResponse)
);
messageReceiver.addEventListener(
'profileKeyUpdate',
queuedEventListener(onProfileKeyUpdate)
);
messageReceiver.addEventListener(
'fetchLatest',
queuedEventListener(onFetchLatestSync)
);
messageReceiver.addEventListener('keys', queuedEventListener(onKeysSync));
}); });
ourProfileKeyService.initialize(window.storage); ourProfileKeyService.initialize(window.storage);
@ -378,16 +496,11 @@ export async function startApp(): Promise<void> {
window.getAccountManager().refreshPreKeys(); window.getAccountManager().refreshPreKeys();
}); });
let messageReceiver: MessageReceiver | undefined;
let preMessageReceiverStatus: SocketStatus | undefined;
window.getSocketStatus = () => { window.getSocketStatus = () => {
if (messageReceiver) { if (server === undefined) {
return messageReceiver.getStatus();
}
if (preMessageReceiverStatus) {
return preMessageReceiverStatus;
}
return SocketStatus.CLOSED; return SocketStatus.CLOSED;
}
return server.getSocketStatus();
}; };
let accountManager: typeof window.textsecure.AccountManager; let accountManager: typeof window.textsecure.AccountManager;
window.getAccountManager = () => { window.getAccountManager = () => {
@ -638,15 +751,15 @@ export async function startApp(): Promise<void> {
// Stop processing incoming messages // Stop processing incoming messages
if (messageReceiver) { if (messageReceiver) {
strictAssert(
server !== undefined,
'WebAPI should be initialized together with MessageReceiver'
);
server.unregisterRequestHandler(messageReceiver);
await messageReceiver.stopProcessing(); await messageReceiver.stopProcessing();
await window.waitForAllBatchers(); await window.waitForAllBatchers();
} }
if (messageReceiver) {
messageReceiver.unregisterBatchers();
messageReceiver = undefined;
}
// A number of still-to-queue database queries might be waiting inside batchers. // A number of still-to-queue database queries might be waiting inside batchers.
// We wait for these to empty first, and then shut down the data interface. // We wait for these to empty first, and then shut down the data interface.
await Promise.all([ await Promise.all([
@ -1658,27 +1771,21 @@ export async function startApp(): Promise<void> {
window.Whisper.events.on('powerMonitorResume', () => { window.Whisper.events.on('powerMonitorResume', () => {
window.log.info('powerMonitor: resume'); window.log.info('powerMonitor: resume');
if (!messageReceiver) { server?.checkSockets();
return;
}
messageReceiver.checkSocket();
}); });
const reconnectToWebSocketQueue = new LatestQueue(); const reconnectToWebSocketQueue = new LatestQueue();
const enqueueReconnectToWebSocket = () => { const enqueueReconnectToWebSocket = () => {
reconnectToWebSocketQueue.add(async () => { reconnectToWebSocketQueue.add(async () => {
if (!messageReceiver) { if (!server) {
window.log.info( window.log.info('reconnectToWebSocket: No server. Early return.');
'reconnectToWebSocket: No messageReceiver. Early return.'
);
return; return;
} }
window.log.info('reconnectToWebSocket starting...'); window.log.info('reconnectToWebSocket starting...');
await disconnect(); await server.onOffline();
connect(); await server.onOnline();
window.log.info('reconnectToWebSocket complete.'); window.log.info('reconnectToWebSocket complete.');
}); });
}; };
@ -1688,6 +1795,8 @@ export async function startApp(): Promise<void> {
window._.debounce(enqueueReconnectToWebSocket, 1000, { maxWait: 5000 }) window._.debounce(enqueueReconnectToWebSocket, 1000, { maxWait: 5000 })
); );
window.Whisper.events.on('unlinkAndDisconnect', unlinkAndDisconnect);
function runStorageService() { function runStorageService() {
window.Signal.Services.enableStorageService(); window.Signal.Services.enableStorageService();
@ -2011,8 +2120,13 @@ export async function startApp(): Promise<void> {
disconnectTimer = undefined; disconnectTimer = undefined;
AttachmentDownloads.stop(); AttachmentDownloads.stop();
if (messageReceiver) { if (server !== undefined) {
await messageReceiver.close(); strictAssert(
messageReceiver !== undefined,
'WebAPI should be initialized together with MessageReceiver'
);
await server.onOffline();
await messageReceiver.drain();
} }
} }
@ -2056,19 +2170,6 @@ export async function startApp(): Promise<void> {
return; return;
} }
preMessageReceiverStatus = SocketStatus.CONNECTING;
if (messageReceiver) {
await messageReceiver.stopProcessing();
await window.waitForAllBatchers();
}
if (messageReceiver) {
messageReceiver.unregisterBatchers();
messageReceiver = undefined;
}
window.textsecure.messaging = new window.textsecure.MessageSender(server); window.textsecure.messaging = new window.textsecure.MessageSender(server);
if (connectCount === 0) { if (connectCount === 0) {
@ -2115,132 +2216,20 @@ export async function startApp(): Promise<void> {
window.Whisper.deliveryReceiptQueue.pause(); window.Whisper.deliveryReceiptQueue.pause();
window.Whisper.Notifications.disable(); window.Whisper.Notifications.disable();
// initialize the socket and start listening for messages
window.log.info('Initializing socket and listening for messages');
const messageReceiverOptions = {
serverTrustRoot: window.getServerTrustRoot(),
};
messageReceiver = new window.textsecure.MessageReceiver(
server,
messageReceiverOptions
);
window.textsecure.messageReceiver = messageReceiver;
window.Signal.Services.initializeGroupCredentialFetcher(); window.Signal.Services.initializeGroupCredentialFetcher();
preMessageReceiverStatus = undefined; strictAssert(server !== undefined, 'WebAPI not initialized');
strictAssert(
messageReceiver !== undefined,
'MessageReceiver not initialized'
);
messageReceiver.reset();
server.registerRequestHandler(messageReceiver);
// eslint-disable-next-line no-inner-declarations // If coming here after `offline` event - connect again.
function queuedEventListener<Args extends Array<unknown>>( await server.onOnline();
handler: (...args: Args) => Promise<void> | void,
track = true
): (...args: Args) => void {
return (...args: Args): void => {
eventHandlerQueue.add(async () => {
try {
await handler(...args);
} finally {
// message/sent: Message.handleDataMessage has its own queue and will
// trigger this event itself when complete.
// error: Error processing (below) also has its own queue and self-trigger.
if (track) {
window.Whisper.events.trigger('incrementProgress');
}
}
});
};
}
messageReceiver.addEventListener(
'message',
queuedEventListener(onMessageReceived, false)
);
messageReceiver.addEventListener(
'delivery',
queuedEventListener(onDeliveryReceipt)
);
messageReceiver.addEventListener(
'contact',
queuedEventListener(onContactReceived)
);
messageReceiver.addEventListener(
'contactSync',
queuedEventListener(onContactSyncComplete)
);
messageReceiver.addEventListener(
'group',
queuedEventListener(onGroupReceived)
);
messageReceiver.addEventListener(
'groupSync',
queuedEventListener(onGroupSyncComplete)
);
messageReceiver.addEventListener(
'sent',
queuedEventListener(onSentMessage, false)
);
messageReceiver.addEventListener(
'readSync',
queuedEventListener(onReadSync)
);
messageReceiver.addEventListener(
'read',
queuedEventListener(onReadReceipt)
);
messageReceiver.addEventListener(
'view',
queuedEventListener(onViewReceipt)
);
messageReceiver.addEventListener(
'verified',
queuedEventListener(onVerified)
);
messageReceiver.addEventListener(
'error',
queuedEventListener(onError, false)
);
messageReceiver.addEventListener(
'decryption-error',
queuedEventListener(onDecryptionError)
);
messageReceiver.addEventListener(
'retry-request',
queuedEventListener(onRetryRequest)
);
messageReceiver.addEventListener('empty', queuedEventListener(onEmpty));
messageReceiver.addEventListener(
'reconnect',
queuedEventListener(onReconnect)
);
messageReceiver.addEventListener(
'configuration',
queuedEventListener(onConfiguration)
);
messageReceiver.addEventListener('typing', queuedEventListener(onTyping));
messageReceiver.addEventListener(
'sticker-pack',
queuedEventListener(onStickerPack)
);
messageReceiver.addEventListener(
'viewOnceOpenSync',
queuedEventListener(onViewOnceOpenSync)
);
messageReceiver.addEventListener(
'messageRequestResponse',
queuedEventListener(onMessageRequestResponse)
);
messageReceiver.addEventListener(
'profileKeyUpdate',
queuedEventListener(onProfileKeyUpdate)
);
messageReceiver.addEventListener(
'fetchLatest',
queuedEventListener(onFetchLatestSync)
);
messageReceiver.addEventListener('keys', queuedEventListener(onKeysSync));
AttachmentDownloads.start({ AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
logger: window.log, logger: window.log,
}); });
@ -3511,16 +3500,12 @@ export async function startApp(): Promise<void> {
window.Whisper.events.trigger('unauthorized'); window.Whisper.events.trigger('unauthorized');
if (messageReceiver) { if (messageReceiver) {
strictAssert(server !== undefined, 'WebAPI not initialized');
server.unregisterRequestHandler(messageReceiver);
await messageReceiver.stopProcessing(); await messageReceiver.stopProcessing();
await window.waitForAllBatchers(); await window.waitForAllBatchers();
} }
if (messageReceiver) {
messageReceiver.unregisterBatchers();
messageReceiver = undefined;
}
onEmpty(); onEmpty();
window.log.warn( window.log.warn(

View file

@ -1689,8 +1689,8 @@ export async function createGroupV2({
} }
); );
await conversation.queueJob('storageServiceUploadJob', () => { await conversation.queueJob('storageServiceUploadJob', async () => {
window.Signal.Services.storageServiceUploadJob(); await window.Signal.Services.storageServiceUploadJob();
}); });
const timestamp = Date.now(); const timestamp = Date.now();

View file

@ -14,6 +14,7 @@ import {
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper'; import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
import { isGroupV1 } from '../util/whatTypeOfConversation'; import { isGroupV1 } from '../util/whatTypeOfConversation';
import { explodePromise } from '../util/explodePromise';
import type { ConversationAttributesType } from '../model-types.d'; import type { ConversationAttributesType } from '../model-types.d';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
@ -175,7 +176,7 @@ export async function joinViaLink(hash: string): Promise<void> {
}; };
// Explode a promise so we know when this whole join process is complete // Explode a promise so we know when this whole join process is complete
const { promise, resolve, reject } = explodePromise(); const { promise, resolve, reject } = explodePromise<void>();
const closeDialog = async () => { const closeDialog = async () => {
try { try {
@ -405,26 +406,3 @@ function showErrorDialog(description: string, title: string) {
}, },
}); });
} }
function explodePromise(): {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
} {
let resolve: () => void;
let reject: (error: Error) => void;
const promise = new Promise<void>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return {
promise,
// Typescript thinks that resolve and reject can be undefined here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolve: resolve!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
reject: reject!,
};
}

View file

@ -1,13 +1,12 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isFunction, isNumber, omit } from 'lodash'; import { isNumber, omit } from 'lodash';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import { downloadAttachment } from '../util/downloadAttachment'; import { downloadAttachment } from '../util/downloadAttachment';
import { stringFromBytes } from '../Crypto'; import { stringFromBytes } from '../Crypto';
import MessageReceiver from '../textsecure/MessageReceiver';
import { import {
AttachmentDownloadJobType, AttachmentDownloadJobType,
AttachmentDownloadJobTypeType, AttachmentDownloadJobTypeType,
@ -42,7 +41,6 @@ const RETRY_BACKOFF: Record<number, number> = {
let enabled = false; let enabled = false;
let timeout: NodeJS.Timeout | null; let timeout: NodeJS.Timeout | null;
let getMessageReceiver: () => MessageReceiver | undefined;
let logger: LoggerType; let logger: LoggerType;
const _activeAttachmentDownloadJobs: Record< const _activeAttachmentDownloadJobs: Record<
string, string,
@ -50,17 +48,11 @@ const _activeAttachmentDownloadJobs: Record<
> = {}; > = {};
type StartOptionsType = { type StartOptionsType = {
getMessageReceiver: () => MessageReceiver | undefined;
logger: LoggerType; logger: LoggerType;
}; };
export async function start(options: StartOptionsType): Promise<void> { export async function start(options: StartOptionsType): Promise<void> {
({ getMessageReceiver, logger } = options); ({ logger } = options);
if (!isFunction(getMessageReceiver)) {
throw new Error(
'attachment_downloads/start: getMessageReceiver must be a function'
);
}
if (!logger) { if (!logger) {
throw new Error('attachment_downloads/start: logger must be provided!'); throw new Error('attachment_downloads/start: logger must be provided!');
} }
@ -220,11 +212,6 @@ async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
const pending = true; const pending = true;
await setAttachmentDownloadJobPending(id, pending); await setAttachmentDownloadJobPending(id, pending);
const messageReceiver = getMessageReceiver();
if (!messageReceiver) {
throw new Error('_runJob: messageReceiver not found');
}
const downloaded = await downloadAttachment(attachment); const downloaded = await downloadAttachment(attachment);
if (!downloaded) { if (!downloaded) {

View file

@ -6,17 +6,9 @@
import { Collection, Model } from 'backbone'; import { Collection, Model } from 'backbone';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { isOutgoing } from '../state/selectors/message'; import { isOutgoing } from '../state/selectors/message';
import { ReactionAttributesType } from '../model-types.d';
type ReactionsAttributesType = { export class ReactionModel extends Model<ReactionAttributesType> {}
emoji: string;
remove: boolean;
targetAuthorUuid: string;
targetTimestamp: number;
timestamp: number;
fromId: string;
};
export class ReactionModel extends Model<ReactionsAttributesType> {}
let singleton: Reactions | undefined; let singleton: Reactions | undefined;
@ -63,7 +55,7 @@ export class Reactions extends Collection {
async onReaction( async onReaction(
reaction: ReactionModel reaction: ReactionModel
): Promise<ReactionModel | undefined> { ): Promise<ReactionAttributesType | undefined> {
try { try {
// The conversation the target message was in; we have to find it in the database // The conversation the target message was in; we have to find it in the database
// to to figure that out. // to to figure that out.

View file

@ -16,6 +16,7 @@ import { AttachmentType } from '../types/Attachment';
import { CallMode, CallHistoryDetailsType } from '../types/Calling'; import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import * as Stickers from '../types/Stickers'; import * as Stickers from '../types/Stickers';
import { GroupV2InfoType } from '../textsecure/SendMessage'; import { GroupV2InfoType } from '../textsecure/SendMessage';
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
import { CallbackResultType } from '../textsecure/Types.d'; import { CallbackResultType } from '../textsecure/Types.d';
import { ConversationType } from '../state/ducks/conversations'; import { ConversationType } from '../state/ducks/conversations';
import { import {
@ -1058,7 +1059,7 @@ export class ConversationModel extends window.Backbone
this.setRegistered(); this.setRegistered();
// If we couldn't apply universal timer before - try it again. // If we couldn't apply universal timer before - try it again.
this.queueJob('maybeSetPendingUniversalTimer', () => this.queueJob('maybeSetPendingUniversalTimer', async () =>
this.maybeSetPendingUniversalTimer() this.maybeSetPendingUniversalTimer()
); );
} }
@ -2893,13 +2894,10 @@ export class ConversationModel extends window.Backbone
return null; return null;
} }
queueJob( queueJob<T>(name: string, callback: () => Promise<T>): Promise<T> {
name: string,
callback: () => unknown | Promise<unknown>
): Promise<WhatIsThis> {
this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 }); this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 });
const taskWithTimeout = window.textsecure.createTaskWithTimeout( const taskWithTimeout = createTaskWithTimeout(
callback, callback,
`conversation ${this.idForLogging()}` `conversation ${this.idForLogging()}`
); );
@ -3231,7 +3229,7 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destination = this.getSendTarget()!; const destination = this.getSendTarget()!;
return this.queueJob('sendDeleteForEveryone', async () => { await this.queueJob('sendDeleteForEveryone', async () => {
window.log.info( window.log.info(
'Sending deleteForEveryone to conversation', 'Sending deleteForEveryone to conversation',
this.idForLogging(), this.idForLogging(),
@ -3782,7 +3780,7 @@ export class ConversationModel extends window.Backbone
return; return;
} }
this.queueJob('maybeSetPendingUniversalTimer', () => this.queueJob('maybeSetPendingUniversalTimer', async () =>
this.maybeSetPendingUniversalTimer() this.maybeSetPendingUniversalTimer()
); );
@ -4804,7 +4802,7 @@ export class ConversationModel extends window.Backbone
); );
this.set({ needsStorageServiceSync: true }); this.set({ needsStorageServiceSync: true });
this.queueJob('captureChange', () => { this.queueJob('captureChange', async () => {
Services.storageServiceUploadJob(); Services.storageServiceUploadJob();
}); });
} }

View file

@ -0,0 +1,47 @@
// Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { sleep } from '../util/sleep';
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
describe('createTaskWithTimeout', () => {
it('resolves when promise resolves', async () => {
const task = () => Promise.resolve('hi!');
const taskWithTimeout = createTaskWithTimeout(task, 'test');
const result = await taskWithTimeout();
assert.strictEqual(result, 'hi!');
});
it('flows error from promise back', async () => {
const error = new Error('original');
const task = () => Promise.reject(error);
const taskWithTimeout = createTaskWithTimeout(task, 'test');
await assert.isRejected(taskWithTimeout(), 'original');
});
it('rejects if promise takes too long (this one logs error to console)', async () => {
const task = async () => {
await sleep(3000);
};
const taskWithTimeout = createTaskWithTimeout(task, 'test', {
timeout: 10,
});
await assert.isRejected(taskWithTimeout());
});
it('rejects if task throws (and does not log about taking too long)', async () => {
const error = new Error('Task is throwing!');
const task = () => {
throw error;
};
const taskWithTimeout = createTaskWithTimeout(task, 'test', {
timeout: 10,
});
await assert.isRejected(taskWithTimeout(), 'Task is throwing!');
});
});

View file

@ -0,0 +1,46 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { noop } from 'lodash';
import { AbortableProcess } from '../../util/AbortableProcess';
describe('AbortableProcess', () => {
it('resolves the result normally', async () => {
const process = new AbortableProcess(
'process',
{ abort: noop },
Promise.resolve(42)
);
assert.strictEqual(await process.getResult(), 42);
});
it('rejects normally', async () => {
const process = new AbortableProcess(
'process',
{ abort: noop },
Promise.reject(new Error('rejected'))
);
await assert.isRejected(process.getResult(), 'rejected');
});
it('rejects on abort', async () => {
let calledAbort = false;
const process = new AbortableProcess(
'A',
{
abort() {
calledAbort = true;
},
},
new Promise(noop)
);
process.abort();
await assert.isRejected(process.getResult(), 'Process "A" was aborted');
assert.isTrue(calledAbort);
});
});

View file

@ -0,0 +1,26 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable no-restricted-syntax */
import { assert } from 'chai';
import { explodePromise } from '../../util/explodePromise';
describe('explodePromise', () => {
it('resolves the promise', async () => {
const { promise, resolve } = explodePromise<number>();
resolve(42);
assert.strictEqual(await promise, 42);
});
it('rejects the promise', async () => {
const { promise, reject } = explodePromise<number>();
reject(new Error('rejected'));
await assert.isRejected(promise, 'rejected');
});
});

View file

@ -6,12 +6,11 @@
*/ */
import { assert } from 'chai'; import { assert } from 'chai';
import EventEmitter from 'events';
import { connection as WebSocket } from 'websocket';
import MessageReceiver from '../textsecure/MessageReceiver'; import MessageReceiver from '../textsecure/MessageReceiver';
import { DecryptionErrorEvent } from '../textsecure/messageReceiverEvents'; import { IncomingWebSocketRequest } from '../textsecure/WebsocketResources';
import { WebAPIType } from '../textsecure/WebAPI'; import { WebAPIType } from '../textsecure/WebAPI';
import { DecryptionErrorEvent } from '../textsecure/messageReceiverEvents';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import * as Crypto from '../Crypto'; import * as Crypto from '../Crypto';
@ -19,23 +18,16 @@ import * as Crypto from '../Crypto';
const FIXMEU8 = Uint8Array; const FIXMEU8 = Uint8Array;
describe('MessageReceiver', () => { describe('MessageReceiver', () => {
class FakeSocket extends EventEmitter {
public sendBytes(_: Uint8Array) {}
public close() {}
}
const number = '+19999999999'; const number = '+19999999999';
const uuid = 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee'; const uuid = 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee';
const deviceId = 1; const deviceId = 1;
describe('connecting', () => { describe('connecting', () => {
it('generates decryption-error event when it cannot decrypt', done => { it('generates decryption-error event when it cannot decrypt', done => {
const socket = new FakeSocket(); const messageReceiver = new MessageReceiver({
server: {} as WebAPIType,
const messageReceiver = new MessageReceiver({} as WebAPIType, { storage: window.storage,
serverTrustRoot: 'AAAAAAAA', serverTrustRoot: 'AAAAAAAA',
socket: socket as WebSocket,
}); });
const body = Proto.Envelope.encode({ const body = Proto.Envelope.encode({
@ -47,15 +39,18 @@ describe('MessageReceiver', () => {
content: new FIXMEU8(Crypto.getRandomBytes(200)), content: new FIXMEU8(Crypto.getRandomBytes(200)),
}).finish(); }).finish();
const message = Proto.WebSocketMessage.encode({ messageReceiver.handleRequest(
type: Proto.WebSocketMessage.Type.REQUEST, new IncomingWebSocketRequest(
request: { id: 1, verb: 'PUT', path: '/api/v1/message', body }, {
}).finish(); id: 1,
verb: 'PUT',
socket.emit('message', { path: '/api/v1/message',
type: 'binary', body,
binaryData: message, headers: [],
}); },
(_: Buffer): void => {}
)
);
messageReceiver.addEventListener( messageReceiver.addEventListener(
'decryption-error', 'decryption-error',

View file

@ -85,7 +85,7 @@ describe('WebSocket-Resource', () => {
}); });
}); });
it('sends requests and receives responses', done => { it('sends requests and receives responses', async () => {
// mock socket and request handler // mock socket and request handler
let requestId: number | Long | undefined; let requestId: number | Long | undefined;
const socket = new FakeSocket(); const socket = new FakeSocket();
@ -101,16 +101,10 @@ describe('WebSocket-Resource', () => {
// actual test // actual test
const resource = new WebSocketResource(socket as WebSocket); const resource = new WebSocketResource(socket as WebSocket);
resource.sendRequest({ const promise = resource.sendRequest({
verb: 'PUT', verb: 'PUT',
path: '/some/path', path: '/some/path',
body: new Uint8Array([1, 2, 3]), body: new Uint8Array([1, 2, 3]),
error: done,
success(message: string, status: number) {
assert.strictEqual(message, 'OK');
assert.strictEqual(status, 200);
done();
},
}); });
// mock socket response // mock socket response
@ -121,6 +115,10 @@ describe('WebSocket-Resource', () => {
response: { id: requestId, message: 'OK', status: 200 }, response: { id: requestId, message: 'OK', status: 200 },
}).finish(), }).finish(),
}); });
const { status, message } = await promise;
assert.strictEqual(message, 'OK');
assert.strictEqual(status, 200);
}); });
}); });

9
ts/textsecure.d.ts vendored
View file

@ -4,7 +4,6 @@
import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client'; import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client';
import Crypto from './textsecure/Crypto'; import Crypto from './textsecure/Crypto';
import MessageReceiver from './textsecure/MessageReceiver';
import MessageSender from './textsecure/SendMessage'; import MessageSender from './textsecure/SendMessage';
import SyncRequest from './textsecure/SyncRequest'; import SyncRequest from './textsecure/SyncRequest';
import EventTarget from './textsecure/EventTarget'; import EventTarget from './textsecure/EventTarget';
@ -38,20 +37,14 @@ export type UnprocessedType = {
export { StorageServiceCallOptionsType, StorageServiceCredentials }; export { StorageServiceCallOptionsType, StorageServiceCredentials };
export type TextSecureType = { export type TextSecureType = {
createTaskWithTimeout: (
task: () => Promise<any> | any,
id?: string,
options?: { timeout?: number }
) => () => Promise<any>;
crypto: typeof Crypto; crypto: typeof Crypto;
storage: Storage; storage: Storage;
messageReceiver: MessageReceiver; server: WebAPIType;
messageSender: MessageSender; messageSender: MessageSender;
messaging: SendMessage; messaging: SendMessage;
utils: typeof utils; utils: typeof utils;
EventTarget: typeof EventTarget; EventTarget: typeof EventTarget;
MessageReceiver: typeof MessageReceiver;
AccountManager: WhatIsThis; AccountManager: WhatIsThis;
MessageSender: typeof MessageSender; MessageSender: typeof MessageSender;
SyncRequest: typeof SyncRequest; SyncRequest: typeof SyncRequest;

View file

@ -13,9 +13,8 @@ import { WebAPIType } from './WebAPI';
import { KeyPairType, CompatSignedPreKeyType } from './Types.d'; import { KeyPairType, CompatSignedPreKeyType } from './Types.d';
import utils from './Helpers'; import utils from './Helpers';
import ProvisioningCipher from './ProvisioningCipher'; import ProvisioningCipher from './ProvisioningCipher';
import WebSocketResource, { import { IncomingWebSocketRequest } from './WebsocketResources';
IncomingWebSocketRequest, import createTaskWithTimeout from './TaskWithTimeout';
} from './WebsocketResources';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { import {
deriveAccessKey, deriveAccessKey,
@ -192,31 +191,22 @@ export default class AccountManager extends EventTarget {
SIGNED_KEY_GEN_BATCH_SIZE, SIGNED_KEY_GEN_BATCH_SIZE,
progressCallback progressCallback
); );
const confirmKeys = this.confirmKeys.bind(this);
const registrationDone = this.registrationDone.bind(this);
const registerKeys = this.server.registerKeys.bind(this.server);
const getSocket = this.server.getProvisioningSocket.bind(this.server);
const queueTask = this.queueTask.bind(this);
const provisioningCipher = new ProvisioningCipher(); const provisioningCipher = new ProvisioningCipher();
let gotProvisionEnvelope = false;
const pubKey = await provisioningCipher.getPublicKey(); const pubKey = await provisioningCipher.getPublicKey();
const socket = await getSocket(); let envelopeCallbacks:
| {
window.log.info('provisioning socket open'); resolve(data: Proto.ProvisionEnvelope): void;
reject(error: Error): void;
return new Promise((resolve, reject) => {
socket.on('close', (code, reason) => {
window.log.info(
`provisioning socket closed. Code: ${code} Reason: ${reason}`
);
if (!gotProvisionEnvelope) {
reject(new Error('websocket closed'));
} }
}); | undefined;
const envelopePromise = new Promise<Proto.ProvisionEnvelope>(
(resolve, reject) => {
envelopeCallbacks = { resolve, reject };
}
);
const wsr = new WebSocketResource(socket, { const wsr = await this.server.getProvisioningResource({
keepalive: { path: '/v1/keepalive/provisioning' },
handleRequest(request: IncomingWebSocketRequest) { handleRequest(request: IncomingWebSocketRequest) {
if ( if (
request.path === '/v1/address' && request.path === '/v1/address' &&
@ -243,19 +233,31 @@ export default class AccountManager extends EventTarget {
) { ) {
const envelope = Proto.ProvisionEnvelope.decode(request.body); const envelope = Proto.ProvisionEnvelope.decode(request.body);
request.respond(200, 'OK'); request.respond(200, 'OK');
gotProvisionEnvelope = true;
wsr.close(); wsr.close();
resolve( envelopeCallbacks?.resolve(envelope);
provisioningCipher } else {
.decrypt(envelope) window.log.error('Unknown websocket message', request.path);
.then(async provisionMessage => }
queueTask(async () => },
confirmNumber(provisionMessage.number).then( });
async deviceName => {
if ( window.log.info('provisioning socket open');
typeof deviceName !== 'string' ||
deviceName.length === 0 wsr.addEventListener('close', ({ code, reason }) => {
) { window.log.info(
`provisioning socket closed. Code: ${code} Reason: ${reason}`
);
// Note: if we have resolved the envelope already - this has no effect
envelopeCallbacks?.reject(new Error('websocket closed'));
});
const envelope = await envelopePromise;
const provisionMessage = await provisioningCipher.decrypt(envelope);
await this.queueTask(async () => {
const deviceName = await confirmNumber(provisionMessage.number);
if (typeof deviceName !== 'string' || deviceName.length === 0) {
throw new Error( throw new Error(
'AccountManager.registerSecondDevice: Invalid device name' 'AccountManager.registerSecondDevice: Invalid device name'
); );
@ -270,7 +272,7 @@ export default class AccountManager extends EventTarget {
); );
} }
return createAccount( await createAccount(
provisionMessage.number, provisionMessage.number,
provisionMessage.provisioningCode, provisionMessage.provisioningCode,
provisionMessage.identityKeyPair, provisionMessage.identityKeyPair,
@ -279,25 +281,12 @@ export default class AccountManager extends EventTarget {
provisionMessage.userAgent, provisionMessage.userAgent,
provisionMessage.readReceipts, provisionMessage.readReceipts,
{ uuid: provisionMessage.uuid } { uuid: provisionMessage.uuid }
)
.then(clearSessionsAndPreKeys)
.then(generateKeys)
.then(async (keys: GeneratedKeysType) =>
registerKeys(keys).then(async () =>
confirmKeys(keys)
)
)
.then(registrationDone);
}
)
)
)
); );
} else { await clearSessionsAndPreKeys();
window.log.error('Unknown websocket message', request.path); const keys = await generateKeys();
} await this.server.registerKeys(keys);
}, await this.confirmKeys(keys);
}); await this.registrationDone();
}); });
} }
@ -416,7 +405,7 @@ export default class AccountManager extends EventTarget {
async queueTask(task: () => Promise<any>) { async queueTask(task: () => Promise<any>) {
this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 }); this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 });
const taskWithTimeout = window.textsecure.createTaskWithTimeout(task); const taskWithTimeout = createTaskWithTimeout(task, 'AccountManager task');
return this.pendingQueue.add(taskWithTimeout); return this.pendingQueue.add(taskWithTimeout);
} }
@ -633,6 +622,11 @@ export default class AccountManager extends EventTarget {
); );
await window.textsecure.storage.put('regionCode', regionCode); await window.textsecure.storage.put('regionCode', regionCode);
await window.textsecure.storage.protocol.hydrateCaches(); await window.textsecure.storage.protocol.hydrateCaches();
// We are finally ready to reconnect
window.textsecure.storage.user.emitCredentialsChanged(
'AccountManager.createAccount'
);
} }
async clearSessionsAndPreKeys() { async clearSessionsAndPreKeys() {

View file

@ -13,6 +13,40 @@ function appendStack(newError: Error, originalError: Error) {
newError.stack += `\nOriginal stack:\n${originalError.stack}`; newError.stack += `\nOriginal stack:\n${originalError.stack}`;
} }
export type HTTPErrorHeadersType = {
[name: string]: string | ReadonlyArray<string>;
};
export class HTTPError extends Error {
public readonly name = 'HTTPError';
public readonly code: number;
public readonly responseHeaders: HTTPErrorHeadersType;
public readonly response: unknown;
constructor(
message: string,
options: {
code: number;
headers: HTTPErrorHeadersType;
response?: unknown;
stack?: string;
}
) {
super(`${message}; code: ${options.code}`);
const { code: providedCode, headers, response, stack } = options;
this.code = providedCode > 999 || providedCode < 100 ? -1 : providedCode;
this.responseHeaders = headers;
this.stack += `\nOriginal stack:\n${stack}`;
this.response = response;
}
}
export class ReplayableError extends Error { export class ReplayableError extends Error {
name: string; name: string;

View file

@ -6,10 +6,9 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
import { isNumber, map, omit } from 'lodash'; import { isNumber, map } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
import { connection as WebSocket } from 'websocket';
import { import {
DecryptionErrorMessage, DecryptionErrorMessage,
@ -38,42 +37,37 @@ import {
SignedPreKeys, SignedPreKeys,
} from '../LibSignalStores'; } from '../LibSignalStores';
import { verifySignature } from '../Curve'; import { verifySignature } from '../Curve';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { BatcherType, createBatcher } from '../util/batcher'; import { BatcherType, createBatcher } from '../util/batcher';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { normalizeUuid } from '../util/normalizeUuid'; import { normalizeUuid } from '../util/normalizeUuid';
import { normalizeNumber } from '../util/normalizeNumber'; import { normalizeNumber } from '../util/normalizeNumber';
import { sleep } from '../util/sleep';
import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { Zone } from '../util/Zone'; import { Zone } from '../util/Zone';
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
import { DownloadedAttachmentType } from '../types/Attachment';
import * as Errors from '../types/errors';
import { SignalService as Proto } from '../protobuf';
import { UnprocessedType } from '../textsecure.d';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
import createTaskWithTimeout from './TaskWithTimeout';
import { processAttachment, processDataMessage } from './processDataMessage'; import { processAttachment, processDataMessage } from './processDataMessage';
import { processSyncMessage } from './processSyncMessage'; import { processSyncMessage } from './processSyncMessage';
import EventTarget, { EventHandler } from './EventTarget'; import EventTarget, { EventHandler } from './EventTarget';
import { WebAPIType } from './WebAPI'; import { downloadAttachment } from './downloadAttachment';
import WebSocketResource, { import { IncomingWebSocketRequest } from './WebsocketResources';
IncomingWebSocketRequest,
CloseEvent,
} from './WebsocketResources';
import { ConnectTimeoutError } from './Errors';
import * as Bytes from '../Bytes';
import Crypto from './Crypto';
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
import { ContactBuffer, GroupBuffer } from './ContactsParser'; import { ContactBuffer, GroupBuffer } from './ContactsParser';
import { DownloadedAttachmentType } from '../types/Attachment'; import type { WebAPIType } from './WebAPI';
import * as Errors from '../types/errors'; import type { Storage } from './Storage';
import * as MIME from '../types/MIME'; import * as Bytes from '../Bytes';
import { SocketStatus } from '../types/SocketStatus';
import { SignalService as Proto } from '../protobuf';
import { UnprocessedType } from '../textsecure.d';
import { import {
ProcessedAttachment,
ProcessedDataMessage, ProcessedDataMessage,
ProcessedSyncMessage, ProcessedSyncMessage,
ProcessedSent, ProcessedSent,
ProcessedEnvelope, ProcessedEnvelope,
IRequestHandler,
} from './Types.d'; } from './Types.d';
import { import {
ReconnectEvent, ReconnectEvent,
@ -103,8 +97,6 @@ import {
GroupSyncEvent, GroupSyncEvent,
} from './messageReceiverEvents'; } from './messageReceiverEvents';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
// TODO: remove once we move away from ArrayBuffers // TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array; const FIXMEU8 = Uint8Array;
@ -150,12 +142,18 @@ enum TaskType {
Decrypted = 'Decrypted', Decrypted = 'Decrypted',
} }
export default class MessageReceiver extends EventTarget { export type MessageReceiverOptions = {
private _onClose?: (code: number, reason: string) => Promise<void>; server: WebAPIType;
storage: Storage;
serverTrustRoot: string;
};
private _onWSRClose?: (event: CloseEvent) => void; export default class MessageReceiver
extends EventTarget
implements IRequestHandler {
private server: WebAPIType;
private _onError?: (error: Error) => Promise<void>; private storage: Storage;
private appQueue: PQueue; private appQueue: PQueue;
@ -163,22 +161,14 @@ export default class MessageReceiver extends EventTarget {
private cacheRemoveBatcher: BatcherType<string>; private cacheRemoveBatcher: BatcherType<string>;
private calledClose?: boolean;
private count: number; private count: number;
private processedCount: number; private processedCount: number;
private deviceId?: number;
private hasConnected = false;
private incomingQueue: PQueue; private incomingQueue: PQueue;
private isEmptied?: boolean; private isEmptied?: boolean;
private number_id?: string;
private encryptedQueue: PQueue; private encryptedQueue: PQueue;
private decryptedQueue: PQueue; private decryptedQueue: PQueue;
@ -187,38 +177,21 @@ export default class MessageReceiver extends EventTarget {
private serverTrustRoot: Uint8Array; private serverTrustRoot: Uint8Array;
private socket?: WebSocket;
private socketStatus = SocketStatus.CLOSED;
private stoppingProcessing?: boolean; private stoppingProcessing?: boolean;
private uuid_id?: string; constructor({ server, storage, serverTrustRoot }: MessageReceiverOptions) {
private wsr?: WebSocketResource;
private readonly reconnectBackOff = new BackOff(FIBONACCI_TIMEOUTS);
constructor(
public readonly server: WebAPIType,
options: {
serverTrustRoot: string;
socket?: WebSocket;
}
) {
super(); super();
this.server = server;
this.storage = storage;
this.count = 0; this.count = 0;
this.processedCount = 0; this.processedCount = 0;
if (!options.serverTrustRoot) { if (!serverTrustRoot) {
throw new Error('Server trust root is required!'); throw new Error('Server trust root is required!');
} }
this.serverTrustRoot = Bytes.fromBase64(options.serverTrustRoot); this.serverTrustRoot = Bytes.fromBase64(serverTrustRoot);
this.number_id = window.textsecure.storage.user.getNumber();
this.uuid_id = window.textsecure.storage.user.getUuid();
this.deviceId = window.textsecure.storage.user.getDeviceId();
this.incomingQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 }); this.incomingQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 });
this.appQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 }); this.appQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 });
@ -249,174 +222,124 @@ export default class MessageReceiver extends EventTarget {
maxSize: 30, maxSize: 30,
processBatch: this.cacheRemoveBatch.bind(this), processBatch: this.cacheRemoveBatch.bind(this),
}); });
this.connect(options.socket);
}
public async stopProcessing(): Promise<void> {
window.log.info('MessageReceiver: stopProcessing requested');
this.stoppingProcessing = true;
return this.close();
}
public unregisterBatchers(): void {
window.log.info('MessageReceiver: unregister batchers');
this.decryptAndCacheBatcher.unregister();
this.cacheRemoveBatcher.unregister();
} }
public getProcessedCount(): number { public getProcessedCount(): number {
return this.processedCount; return this.processedCount;
} }
private async connect(socket?: WebSocket): Promise<void> { public handleRequest(request: IncomingWebSocketRequest): void {
if (this.calledClose) { // We do the message decryption here, instead of in the ordered pending queue,
// to avoid exposing the time it took us to process messages through the time-to-ack.
window.log.info('MessageReceiver: got request', request.verb, request.path);
if (request.path !== '/api/v1/message') {
request.respond(200, 'OK');
if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') {
this.incomingQueue.add(() => {
this.onEmpty();
});
}
return; return;
} }
const job = async () => {
const headers = request.headers || [];
if (!request.body) {
throw new Error(
'MessageReceiver.handleRequest: request.body was falsey!'
);
}
const plaintext = request.body;
try {
const decoded = Proto.Envelope.decode(plaintext);
const serverTimestamp = normalizeNumber(decoded.serverTimestamp);
const envelope: ProcessedEnvelope = {
// Make non-private envelope IDs dashless so they don't get redacted
// from logs
id: getGuid().replace(/-/g, ''),
receivedAtCounter: window.Signal.Util.incrementMessageCounter(),
receivedAtDate: Date.now(),
// Calculate the message age (time on server).
messageAgeSec: this.calculateMessageAge(headers, serverTimestamp),
// Proto.Envelope fields
type: decoded.type,
source: decoded.source,
sourceUuid: decoded.sourceUuid
? normalizeUuid(
decoded.sourceUuid,
'MessageReceiver.handleRequest.sourceUuid'
)
: undefined,
sourceDevice: decoded.sourceDevice,
timestamp: normalizeNumber(decoded.timestamp),
legacyMessage: dropNull(decoded.legacyMessage),
content: dropNull(decoded.content),
serverGuid: decoded.serverGuid,
serverTimestamp,
};
// After this point, decoding errors are not the server's
// fault, and we should handle them gracefully and tell the
// user they received an invalid message
if (envelope.source && this.isBlocked(envelope.source)) {
request.respond(200, 'OK');
return;
}
if (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) {
request.respond(200, 'OK');
return;
}
this.decryptAndCache(envelope, plaintext, request);
this.processedCount += 1;
} catch (e) {
request.respond(500, 'Bad encrypted websocket message');
window.log.error(
'Error handling incoming message:',
Errors.toLogFormat(e)
);
await this.dispatchAndWait(new ErrorEvent(e));
}
};
this.incomingQueue.add(job);
}
public reset(): void {
// We always process our cache before processing a new websocket message // We always process our cache before processing a new websocket message
this.incomingQueue.add(async () => this.queueAllCached()); this.incomingQueue.add(async () => this.queueAllCached());
this.count = 0; this.count = 0;
if (this.hasConnected) {
this.dispatchEvent(new ReconnectEvent());
}
this.isEmptied = false; this.isEmptied = false;
this.stoppingProcessing = false;
this.hasConnected = true;
if (this.socket && this.socket.connected) {
this.socket.close();
this.socket = undefined;
if (this.wsr) {
this.wsr.close();
this.wsr = undefined;
}
}
this.socketStatus = SocketStatus.CONNECTING;
// initialize the socket and start listening for messages
try {
this.socket = socket || (await this.server.getMessageSocket());
} catch (error) {
this.socketStatus = SocketStatus.CLOSED;
if (error instanceof ConnectTimeoutError) {
await this.onclose(-1, 'Connection timed out');
return;
} }
await this.dispatchAndWait(new ErrorEvent(error)); public stopProcessing(): void {
return; this.stoppingProcessing = true;
} }
this.socketStatus = SocketStatus.OPEN; public hasEmptied(): boolean {
return Boolean(this.isEmptied);
window.log.info('websocket open');
window.logMessageReceiverConnect();
if (!this._onClose) {
this._onClose = this.onclose.bind(this);
}
if (!this._onWSRClose) {
this._onWSRClose = ({ code, reason }: CloseEvent): void => {
this.onclose(code, reason);
};
}
if (!this._onError) {
this._onError = this.onerror.bind(this);
} }
this.socket.on('close', this._onClose); public async drain(): Promise<void> {
this.socket.on('error', this._onError); const waitForEncryptedQueue = async () =>
this.addToQueue(async () => {
window.log.info('drained');
}, TaskType.Decrypted);
this.wsr = new WebSocketResource(this.socket, { const waitForIncomingQueue = async () =>
handleRequest: this.handleRequest.bind(this), this.addToQueue(waitForEncryptedQueue, TaskType.Encrypted);
keepalive: {
path: '/v1/keepalive',
disconnect: true,
},
});
// Because sometimes the socket doesn't properly emit its close event return this.incomingQueue.add(waitForIncomingQueue);
if (this._onWSRClose) {
this.wsr.addEventListener('close', this._onWSRClose);
}
}
public async close(): Promise<void> {
window.log.info('MessageReceiver.close()');
this.calledClose = true;
this.socketStatus = SocketStatus.CLOSING;
// Our WebSocketResource instance will close the socket and emit a 'close' event
// if the socket doesn't emit one quickly enough.
if (this.wsr) {
this.wsr.close(3000, 'called close');
}
this.clearRetryTimeout();
return this.drain();
}
public checkSocket(): void {
if (this.wsr) {
this.wsr.forceKeepAlive();
}
}
public getStatus(): SocketStatus {
return this.socketStatus;
}
public async downloadAttachment(
attachment: ProcessedAttachment
): Promise<DownloadedAttachmentType> {
const cdnId = attachment.cdnId || attachment.cdnKey;
const { cdnNumber } = attachment;
if (!cdnId) {
throw new Error('downloadAttachment: Attachment was missing cdnId!');
}
strictAssert(cdnId, 'attachment without cdnId');
const encrypted = await this.server.getAttachment(
cdnId,
dropNull(cdnNumber)
);
const { key, digest, size, contentType } = attachment;
if (!digest) {
throw new Error('Failure: Ask sender to update Signal and resend.');
}
strictAssert(key, 'attachment has no key');
strictAssert(digest, 'attachment has no digest');
const paddedData = await Crypto.decryptAttachment(
encrypted,
typedArrayToArrayBuffer(Bytes.fromBase64(key)),
typedArrayToArrayBuffer(Bytes.fromBase64(digest))
);
if (!isNumber(size)) {
throw new Error(
`downloadAttachment: Size was not provided, actual size was ${paddedData.byteLength}`
);
}
const data = window.Signal.Crypto.getFirstBytes(paddedData, size);
return {
...omit(attachment, 'digest', 'key'),
contentType: contentType
? MIME.fromString(contentType)
: MIME.APPLICATION_OCTET_STREAM,
data,
};
} }
// //
@ -548,157 +471,10 @@ export default class MessageReceiver extends EventTarget {
// Private // Private
// //
private shutdown(): void {
if (this.socket) {
if (this._onClose) {
this.socket.removeListener('close', this._onClose);
}
if (this._onError) {
this.socket.removeListener('error', this._onError);
}
this.socket = undefined;
}
if (this.wsr) {
if (this._onWSRClose) {
this.wsr.removeEventListener('close', this._onWSRClose);
}
this.wsr = undefined;
}
}
private async onerror(error: Error): Promise<void> {
window.log.error('websocket error', error);
}
private async dispatchAndWait(event: Event): Promise<void> { private async dispatchAndWait(event: Event): Promise<void> {
this.appQueue.add(async () => Promise.all(this.dispatchEvent(event))); this.appQueue.add(async () => Promise.all(this.dispatchEvent(event)));
} }
private async onclose(code: number, reason: string): Promise<void> {
window.log.info(
'MessageReceiver: websocket closed',
code,
reason || '',
'calledClose:',
this.calledClose
);
this.socketStatus = SocketStatus.CLOSED;
this.shutdown();
if (this.calledClose) {
return;
}
if (code === 3000) {
return;
}
if (code === 3001) {
this.onEmpty();
}
const timeout = this.reconnectBackOff.getAndIncrement();
window.log.info(`MessageReceiver: reconnecting after ${timeout}ms`);
await sleep(timeout);
// Try to reconnect (if there is an HTTP error - we'll get an
// `error` event from `connect()` and hit the secondary retry backoff
// logic in `ts/background.ts`)
await this.connect();
// Successfull reconnect, reset the backoff timeouts
this.reconnectBackOff.reset();
}
private handleRequest(request: IncomingWebSocketRequest): void {
// We do the message decryption here, instead of in the ordered pending queue,
// to avoid exposing the time it took us to process messages through the time-to-ack.
if (request.path !== '/api/v1/message') {
window.log.info('got request', request.verb, request.path);
request.respond(200, 'OK');
if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') {
this.incomingQueue.add(() => {
this.onEmpty();
});
}
return;
}
const job = async () => {
const headers = request.headers || [];
if (!request.body) {
throw new Error(
'MessageReceiver.handleRequest: request.body was falsey!'
);
}
const plaintext = request.body;
try {
const decoded = Proto.Envelope.decode(plaintext);
const serverTimestamp = normalizeNumber(decoded.serverTimestamp);
const envelope: ProcessedEnvelope = {
// Make non-private envelope IDs dashless so they don't get redacted
// from logs
id: getGuid().replace(/-/g, ''),
receivedAtCounter: window.Signal.Util.incrementMessageCounter(),
receivedAtDate: Date.now(),
// Calculate the message age (time on server).
messageAgeSec: this.calculateMessageAge(headers, serverTimestamp),
// Proto.Envelope fields
type: decoded.type,
source: decoded.source,
sourceUuid: decoded.sourceUuid
? normalizeUuid(
decoded.sourceUuid,
'MessageReceiver.handleRequest.sourceUuid'
)
: undefined,
sourceDevice: decoded.sourceDevice,
timestamp: normalizeNumber(decoded.timestamp),
legacyMessage: dropNull(decoded.legacyMessage),
content: dropNull(decoded.content),
serverGuid: decoded.serverGuid,
serverTimestamp,
};
// After this point, decoding errors are not the server's
// fault, and we should handle them gracefully and tell the
// user they received an invalid message
if (envelope.source && this.isBlocked(envelope.source)) {
request.respond(200, 'OK');
return;
}
if (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) {
request.respond(200, 'OK');
return;
}
this.decryptAndCache(envelope, plaintext, request);
this.processedCount += 1;
} catch (e) {
request.respond(500, 'Bad encrypted websocket message');
window.log.error(
'Error handling incoming message:',
Errors.toLogFormat(e)
);
await this.dispatchAndWait(new ErrorEvent(e));
}
};
this.incomingQueue.add(job);
}
private calculateMessageAge( private calculateMessageAge(
headers: ReadonlyArray<string>, headers: ReadonlyArray<string>,
serverTimestamp?: number serverTimestamp?: number
@ -748,10 +524,6 @@ export default class MessageReceiver extends EventTarget {
} }
} }
public hasEmptied(): boolean {
return Boolean(this.isEmptied);
}
private onEmpty(): void { private onEmpty(): void {
const emitEmpty = async () => { const emitEmpty = async () => {
await Promise.all([ await Promise.all([
@ -795,18 +567,6 @@ export default class MessageReceiver extends EventTarget {
waitForCacheAddBatcher(); waitForCacheAddBatcher();
} }
private async drain(): Promise<void> {
const waitForEncryptedQueue = async () =>
this.addToQueue(async () => {
window.log.info('drained');
}, TaskType.Decrypted);
const waitForIncomingQueue = async () =>
this.addToQueue(waitForEncryptedQueue, TaskType.Encrypted);
return this.incomingQueue.add(waitForIncomingQueue);
}
private updateProgress(count: number): void { private updateProgress(count: number): void {
// count by 10s // count by 10s
if (count % 10 !== 0) { if (count % 10 !== 0) {
@ -890,7 +650,7 @@ export default class MessageReceiver extends EventTarget {
try { try {
const { id } = item; const { id } = item;
await window.textsecure.storage.protocol.removeUnprocessed(id); await this.storage.protocol.removeUnprocessed(id);
} catch (deleteError) { } catch (deleteError) {
window.log.error( window.log.error(
'queueCached error deleting item', 'queueCached error deleting item',
@ -931,17 +691,17 @@ export default class MessageReceiver extends EventTarget {
private async getAllFromCache(): Promise<Array<UnprocessedType>> { private async getAllFromCache(): Promise<Array<UnprocessedType>> {
window.log.info('getAllFromCache'); window.log.info('getAllFromCache');
const count = await window.textsecure.storage.protocol.getUnprocessedCount(); const count = await this.storage.protocol.getUnprocessedCount();
if (count > 1500) { if (count > 1500) {
await window.textsecure.storage.protocol.removeAllUnprocessed(); await this.storage.protocol.removeAllUnprocessed();
window.log.warn( window.log.warn(
`There were ${count} messages in cache. Deleted all instead of reprocessing` `There were ${count} messages in cache. Deleted all instead of reprocessing`
); );
return []; return [];
} }
const items = await window.textsecure.storage.protocol.getAllUnprocessed(); const items = await this.storage.protocol.getAllUnprocessed();
window.log.info('getAllFromCache loaded', items.length, 'saved envelopes'); window.log.info('getAllFromCache loaded', items.length, 'saved envelopes');
return Promise.all( return Promise.all(
@ -954,9 +714,9 @@ export default class MessageReceiver extends EventTarget {
'getAllFromCache final attempt for envelope', 'getAllFromCache final attempt for envelope',
item.id item.id
); );
await window.textsecure.storage.protocol.removeUnprocessed(item.id); await this.storage.protocol.removeUnprocessed(item.id);
} else { } else {
await window.textsecure.storage.protocol.updateUnprocessedAttempts( await this.storage.protocol.updateUnprocessedAttempts(
item.id, item.id,
attempts attempts
); );
@ -986,7 +746,7 @@ export default class MessageReceiver extends EventTarget {
}> }>
> = []; > = [];
const storageProtocol = window.textsecure.storage.protocol; const storageProtocol = this.storage.protocol;
try { try {
const zone = new Zone('decryptAndCacheBatch', { const zone = new Zone('decryptAndCacheBatch', {
@ -1124,7 +884,7 @@ export default class MessageReceiver extends EventTarget {
} }
private async cacheRemoveBatch(items: Array<string>): Promise<void> { private async cacheRemoveBatch(items: Array<string>): Promise<void> {
await window.textsecure.storage.protocol.removeUnprocessed(items); await this.storage.protocol.removeUnprocessed(items);
} }
private removeFromCache(envelope: ProcessedEnvelope): void { private removeFromCache(envelope: ProcessedEnvelope): void {
@ -1140,7 +900,7 @@ export default class MessageReceiver extends EventTarget {
window.log.info('queueing decrypted envelope', id); window.log.info('queueing decrypted envelope', id);
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext); const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
const taskWithTimeout = window.textsecure.createTaskWithTimeout( const taskWithTimeout = createTaskWithTimeout(
task, task,
`queueDecryptedEnvelope ${id}` `queueDecryptedEnvelope ${id}`
); );
@ -1163,7 +923,7 @@ export default class MessageReceiver extends EventTarget {
window.log.info('queueing envelope', id); window.log.info('queueing envelope', id);
const task = this.decryptEnvelope.bind(this, stores, envelope); const task = this.decryptEnvelope.bind(this, stores, envelope);
const taskWithTimeout = window.textsecure.createTaskWithTimeout( const taskWithTimeout = createTaskWithTimeout(
task, task,
`queueEncryptedEnvelope ${id}` `queueEncryptedEnvelope ${id}`
); );
@ -1456,10 +1216,10 @@ export default class MessageReceiver extends EventTarget {
envelope: UnsealedEnvelope, envelope: UnsealedEnvelope,
ciphertext: Uint8Array ciphertext: Uint8Array
): Promise<DecryptSealedSenderResult> { ): Promise<DecryptSealedSenderResult> {
const localE164 = window.textsecure.storage.user.getNumber(); const localE164 = this.storage.user.getNumber();
const localUuid = window.textsecure.storage.user.getUuid(); const localUuid = this.storage.user.getUuid();
const localDeviceId = parseIntOrThrow( const localDeviceId = parseIntOrThrow(
window.textsecure.storage.user.getDeviceId(), this.storage.user.getDeviceId(),
'MessageReceiver.decryptSealedSender: localDeviceId' 'MessageReceiver.decryptSealedSender: localDeviceId'
); );
@ -1513,7 +1273,7 @@ export default class MessageReceiver extends EventTarget {
const address = `${sealedSenderIdentifier}.${sealedSenderSourceDevice}`; const address = `${sealedSenderIdentifier}.${sealedSenderSourceDevice}`;
const plaintext = await window.textsecure.storage.protocol.enqueueSenderKeyJob( const plaintext = await this.storage.protocol.enqueueSenderKeyJob(
address, address,
() => () =>
groupDecrypt( groupDecrypt(
@ -1539,7 +1299,7 @@ export default class MessageReceiver extends EventTarget {
const sealedSenderIdentifier = envelope.sourceUuid || envelope.source; const sealedSenderIdentifier = envelope.sourceUuid || envelope.source;
const address = `${sealedSenderIdentifier}.${envelope.sourceDevice}`; const address = `${sealedSenderIdentifier}.${envelope.sourceDevice}`;
const unsealedPlaintext = await window.textsecure.storage.protocol.enqueueSessionJob( const unsealedPlaintext = await this.storage.protocol.enqueueSessionJob(
address, address,
() => () =>
sealedSenderDecryptMessage( sealedSenderDecryptMessage(
@ -1598,7 +1358,7 @@ export default class MessageReceiver extends EventTarget {
const signalMessage = SignalMessage.deserialize(Buffer.from(ciphertext)); const signalMessage = SignalMessage.deserialize(Buffer.from(ciphertext));
const address = `${identifier}.${sourceDevice}`; const address = `${identifier}.${sourceDevice}`;
const plaintext = await window.textsecure.storage.protocol.enqueueSessionJob( const plaintext = await this.storage.protocol.enqueueSessionJob(
address, address,
async () => async () =>
this.unpad( this.unpad(
@ -1630,7 +1390,7 @@ export default class MessageReceiver extends EventTarget {
); );
const address = `${identifier}.${sourceDevice}`; const address = `${identifier}.${sourceDevice}`;
const plaintext = await window.textsecure.storage.protocol.enqueueSessionJob( const plaintext = await this.storage.protocol.enqueueSessionJob(
address, address,
async () => async () =>
this.unpad( this.unpad(
@ -1780,8 +1540,8 @@ export default class MessageReceiver extends EventTarget {
const groupId = this.getProcessedGroupId(message); const groupId = this.getProcessedGroupId(message);
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false; const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
const { source, sourceUuid } = envelope; const { source, sourceUuid } = envelope;
const ourE164 = window.textsecure.storage.user.getNumber(); const ourE164 = this.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid(); const ourUuid = this.storage.user.getUuid();
const isMe = const isMe =
(source && ourE164 && source === ourE164) || (source && ourE164 && source === ourE164) ||
(sourceUuid && ourUuid && sourceUuid === ourUuid); (sourceUuid && ourUuid && sourceUuid === ourUuid);
@ -1869,8 +1629,8 @@ export default class MessageReceiver extends EventTarget {
const groupId = this.getProcessedGroupId(message); const groupId = this.getProcessedGroupId(message);
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false; const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
const { source, sourceUuid } = envelope; const { source, sourceUuid } = envelope;
const ourE164 = window.textsecure.storage.user.getNumber(); const ourE164 = this.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid(); const ourUuid = this.storage.user.getUuid();
const isMe = const isMe =
(source && ourE164 && source === ourE164) || (source && ourE164 && source === ourE164) ||
(sourceUuid && ourUuid && sourceUuid === ourUuid); (sourceUuid && ourUuid && sourceUuid === ourUuid);
@ -2086,7 +1846,7 @@ export default class MessageReceiver extends EventTarget {
const senderKeyStore = new SenderKeys(); const senderKeyStore = new SenderKeys();
const address = `${identifier}.${sourceDevice}`; const address = `${identifier}.${sourceDevice}`;
await window.textsecure.storage.protocol.enqueueSenderKeyJob( await this.storage.protocol.enqueueSenderKeyJob(
address, address,
() => () =>
processSenderKeyDistributionMessage( processSenderKeyDistributionMessage(
@ -2326,15 +2086,19 @@ export default class MessageReceiver extends EventTarget {
envelope: ProcessedEnvelope, envelope: ProcessedEnvelope,
syncMessage: ProcessedSyncMessage syncMessage: ProcessedSyncMessage
): Promise<void> { ): Promise<void> {
const fromSelfSource = const ourNumber = this.storage.user.getNumber();
envelope.source && envelope.source === this.number_id; const ourUuid = this.storage.user.getUuid();
const fromSelfSource = envelope.source && envelope.source === ourNumber;
const fromSelfSourceUuid = const fromSelfSourceUuid =
envelope.sourceUuid && envelope.sourceUuid === this.uuid_id; envelope.sourceUuid && envelope.sourceUuid === ourUuid;
if (!fromSelfSource && !fromSelfSourceUuid) { if (!fromSelfSource && !fromSelfSourceUuid) {
throw new Error('Received sync message from another number'); throw new Error('Received sync message from another number');
} }
const ourDeviceId = this.storage.user.getDeviceId();
// eslint-disable-next-line eqeqeq // eslint-disable-next-line eqeqeq
if (envelope.sourceDevice == this.deviceId) { if (envelope.sourceDevice == ourDeviceId) {
throw new Error('Received sync message from our own device'); throw new Error('Received sync message from our own device');
} }
if (syncMessage.sent) { if (syncMessage.sent) {
@ -2681,14 +2445,14 @@ export default class MessageReceiver extends EventTarget {
): Promise<void> { ): Promise<void> {
window.log.info('Setting these numbers as blocked:', blocked.numbers); window.log.info('Setting these numbers as blocked:', blocked.numbers);
if (blocked.numbers) { if (blocked.numbers) {
await window.textsecure.storage.put('blocked', blocked.numbers); await this.storage.put('blocked', blocked.numbers);
} }
if (blocked.uuids) { if (blocked.uuids) {
const uuids = blocked.uuids.map((uuid, index) => { const uuids = blocked.uuids.map((uuid, index) => {
return normalizeUuid(uuid, `handleBlocked.uuids.${index}`); return normalizeUuid(uuid, `handleBlocked.uuids.${index}`);
}); });
window.log.info('Setting these uuids as blocked:', uuids); window.log.info('Setting these uuids as blocked:', uuids);
await window.textsecure.storage.put('blocked-uuids', uuids); await this.storage.put('blocked-uuids', uuids);
} }
const groupIds = map(blocked.groupIds, groupId => Bytes.toBinary(groupId)); const groupIds = map(blocked.groupIds, groupId => Bytes.toBinary(groupId));
@ -2696,33 +2460,33 @@ export default class MessageReceiver extends EventTarget {
'Setting these groups as blocked:', 'Setting these groups as blocked:',
groupIds.map(groupId => `group(${groupId})`) groupIds.map(groupId => `group(${groupId})`)
); );
await window.textsecure.storage.put('blocked-groups', groupIds); await this.storage.put('blocked-groups', groupIds);
this.removeFromCache(envelope); this.removeFromCache(envelope);
} }
private isBlocked(number: string): boolean { private isBlocked(number: string): boolean {
return window.textsecure.storage.blocked.isBlocked(number); return this.storage.blocked.isBlocked(number);
} }
private isUuidBlocked(uuid: string): boolean { private isUuidBlocked(uuid: string): boolean {
return window.textsecure.storage.blocked.isUuidBlocked(uuid); return this.storage.blocked.isUuidBlocked(uuid);
} }
private isGroupBlocked(groupId: string): boolean { private isGroupBlocked(groupId: string): boolean {
return window.textsecure.storage.blocked.isGroupBlocked(groupId); return this.storage.blocked.isGroupBlocked(groupId);
} }
private async handleAttachment( private async handleAttachment(
attachment: Proto.IAttachmentPointer attachment: Proto.IAttachmentPointer
): Promise<DownloadedAttachmentType> { ): Promise<DownloadedAttachmentType> {
const cleaned = processAttachment(attachment); const cleaned = processAttachment(attachment);
return this.downloadAttachment(cleaned); return downloadAttachment(this.server, cleaned);
} }
private async handleEndSession(identifier: string): Promise<void> { private async handleEndSession(identifier: string): Promise<void> {
window.log.info(`handleEndSession: closing sessions for ${identifier}`); window.log.info(`handleEndSession: closing sessions for ${identifier}`);
await window.textsecure.storage.protocol.archiveAllSessions(identifier); await this.storage.protocol.archiveAllSessions(identifier);
} }
private async processDecrypted( private async processDecrypted(

View file

@ -0,0 +1,627 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable no-restricted-syntax */
import URL from 'url';
import ProxyAgent from 'proxy-agent';
import { RequestInit, Response, Headers } from 'node-fetch';
import { client as WebSocketClient } from 'websocket';
import qs from 'querystring';
import EventListener from 'events';
import { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert';
import { explodePromise } from '../util/explodePromise';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
import { getUserAgent } from '../util/getUserAgent';
import { sleep } from '../util/sleep';
import { SocketStatus } from '../types/SocketStatus';
import * as Errors from '../types/errors';
import * as Bytes from '../Bytes';
import WebSocketResource, {
WebSocketResourceOptions,
IncomingWebSocketRequest,
} from './WebsocketResources';
import { ConnectTimeoutError, HTTPError } from './Errors';
import { handleStatusCode, translateError } from './Utils';
import { WebAPICredentials, IRequestHandler } from './Types.d';
// TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array;
const TEN_SECONDS = 1000 * 10;
const FIVE_MINUTES = 5 * 60 * 1000;
export type SocketManagerOptions = Readonly<{
url: string;
certificateAuthority: string;
version: string;
proxyUrl?: string;
}>;
// This class manages two websocket resource:
//
// - Authenticated WebSocketResource which uses supplied WebAPICredentials and
// automatically reconnects on closed socket (using back off)
// - Unauthenticated WebSocketResource that is created on demand and reconnected
// every 5 minutes.
//
// Incoming requests on authenticated resource are funneled into the registered
// request handlers (`registerRequestHandler`) or queued internally until at
// least one such request handler becomes available.
//
// Incoming requests on unauthenticated resource are not currently supported.
// WebSocketResource is responsible for their immediate termination.
export class SocketManager extends EventListener {
private backOff = new BackOff(FIBONACCI_TIMEOUTS);
private authenticated?: AbortableProcess<WebSocketResource>;
private unauthenticated?: AbortableProcess<WebSocketResource>;
private credentials?: WebAPICredentials;
private readonly proxyAgent?: ProxyAgent;
private status = SocketStatus.CLOSED;
private requestHandlers = new Set<IRequestHandler>();
private incomingRequestQueue = new Array<IncomingWebSocketRequest>();
private isOffline = false;
constructor(private readonly options: SocketManagerOptions) {
super();
if (options.proxyUrl) {
this.proxyAgent = new ProxyAgent(options.proxyUrl);
}
}
public getStatus(): SocketStatus {
return this.status;
}
// Update WebAPICredentials and reconnect authenticated resource if
// credentials changed
public async authenticate(credentials: WebAPICredentials): Promise<void> {
if (this.isOffline) {
throw new HTTPError('SocketManager offline', {
code: 0,
headers: {},
stack: new Error().stack,
});
}
const { username, password } = credentials;
if (!username && !password) {
window.log.warn(
'SocketManager authenticate was called without credentials'
);
return;
}
if (
this.credentials &&
this.credentials.username === username &&
this.credentials.password === password &&
this.authenticated
) {
try {
await this.authenticated.getResult();
} catch (error) {
window.log.warn(
'SocketManager: failed to wait for existing authenticated socket ' +
` due to error: ${Errors.toLogFormat(error)}`
);
}
return;
}
this.credentials = credentials;
window.log.info('SocketManager: connecting authenticated socket');
this.status = SocketStatus.CONNECTING;
const process = this.connectResource({
path: '/v1/websocket/',
query: { login: username, password },
resourceOptions: {
keepalive: { path: '/v1/keepalive' },
handleRequest: (req: IncomingWebSocketRequest): void => {
this.queueOrHandleRequest(req);
},
},
});
// Cancel previous connect attempt or close socket
this.authenticated?.abort();
this.authenticated = process;
const reconnect = async (): Promise<void> => {
const timeout = this.backOff.getAndIncrement();
window.log.info(
'SocketManager: reconnecting authenticated socket ' +
`after ${timeout}ms`
);
await sleep(timeout);
if (this.isOffline) {
window.log.info(
'SocketManager: cancelled reconnect because we are offline'
);
return;
}
if (this.authenticated) {
window.log.info(
'SocketManager: authenticated socket already reconnected'
);
return;
}
strictAssert(this.credentials !== undefined, 'Missing credentials');
try {
await this.authenticate(this.credentials);
} catch (error) {
window.log.info(
'SocketManager: authenticated socket failed to reconect ' +
`due to error ${Errors.toLogFormat(error)}`
);
return reconnect();
}
};
let authenticated: WebSocketResource;
try {
authenticated = await process.getResult();
this.status = SocketStatus.OPEN;
} catch (error) {
strictAssert(this.authenticated === process, 'Someone stole our socket');
this.dropAuthenticated(process);
window.log.warn(
'SocketManager: authenticated socket connection failed with ' +
`error: ${Errors.toLogFormat(error)}`
);
if (error instanceof HTTPError) {
const { code } = error;
if (code === 401 || code === 403) {
this.emit('authError', error);
return;
}
if (code !== 500 && code !== -1) {
// No reconnect attempt should be made
return;
}
}
reconnect();
return;
}
window.log.info('SocketManager: connected authenticated socket');
window.logAuthenticatedConnect();
this.backOff.reset();
authenticated.addEventListener('close', ({ code, reason }): void => {
if (this.authenticated !== process) {
return;
}
window.log.warn(
'SocketManager: authenticated socket closed ' +
`with code=${code} and reason=${reason}`
);
this.dropAuthenticated(process);
if (code === 3000) {
// Intentional disconnect
return;
}
reconnect();
});
}
// Either returns currently connecting/active authenticated
// WebSocketResource or connects a fresh one.
public async getAuthenticatedResource(): Promise<WebSocketResource> {
if (!this.authenticated) {
strictAssert(this.credentials !== undefined, 'Missing credentials');
await this.authenticate(this.credentials);
}
strictAssert(this.authenticated !== undefined, 'Authentication failed');
return this.authenticated.getResult();
}
// Creates new WebSocketResource for AccountManager's provisioning
public async getProvisioningResource(
handler: IRequestHandler
): Promise<WebSocketResource> {
return this.connectResource({
path: '/v1/websocket/provisioning/',
resourceOptions: {
handleRequest: (req: IncomingWebSocketRequest): void => {
handler.handleRequest(req);
},
keepalive: { path: '/v1/keepalive/provisioning' },
},
}).getResult();
}
// Fetch-compatible wrapper around underlying unauthenticated/authenticated
// websocket resources. This wrapper supports only limited number of features
// of node-fetch despite being API compatible.
public async fetch(url: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers);
const isAuthenticated = headers.has('Authorization');
let resource: WebSocketResource;
if (isAuthenticated) {
resource = await this.getAuthenticatedResource();
} else {
resource = await this.getUnauthenticatedResource();
}
const { path } = URL.parse(url);
strictAssert(path, "Fetch can't have empty path");
const { method = 'GET', body, timeout } = init;
let bodyBytes: Uint8Array | undefined;
if (body === undefined) {
bodyBytes = undefined;
} else if (body instanceof Uint8Array) {
bodyBytes = body;
} else if (body instanceof ArrayBuffer) {
bodyBytes = new FIXMEU8(body);
} else if (typeof body === 'string') {
bodyBytes = Bytes.fromString(body);
} else {
throw new Error(`Unsupported body type: ${typeof body}`);
}
const {
status,
message: statusText,
response,
headers: flatResponseHeaders,
} = await resource.sendRequest({
verb: method,
path,
body: bodyBytes,
headers: Array.from(headers.entries()).map(([key, value]) => {
return `${key}:${value}`;
}),
timeout,
});
const responseHeaders: Array<[string, string]> = flatResponseHeaders.map(
header => {
const [key, value] = header.split(':', 2);
strictAssert(value !== undefined, 'Invalid header!');
return [key, value];
}
);
return new Response(response, {
status,
statusText,
headers: responseHeaders,
});
}
public registerRequestHandler(handler: IRequestHandler): void {
this.requestHandlers.add(handler);
const queue = this.incomingRequestQueue;
if (queue.length === 0) {
return;
}
window.log.info(
`SocketManager: processing ${queue.length} queued incoming requests`
);
this.incomingRequestQueue = [];
for (const req of queue) {
this.queueOrHandleRequest(req);
}
}
public unregisterRequestHandler(handler: IRequestHandler): void {
this.requestHandlers.delete(handler);
}
// Force keep-alive checks on WebSocketResources
public async check(): Promise<void> {
if (this.isOffline) {
return;
}
window.log.info('SocketManager.check');
await Promise.all([
SocketManager.checkResource(this.authenticated),
SocketManager.checkResource(this.unauthenticated),
]);
}
// Puts SocketManager into "online" state and reconnects the authenticated
// WebSocketResource (if there are valid credentials)
public async onOnline(): Promise<void> {
window.log.info('SocketManager.onOnline');
this.isOffline = false;
if (this.credentials) {
await this.authenticate(this.credentials);
}
}
// Puts SocketManager into "offline" state and gracefully disconnects both
// unauthenticated and authenticated resources.
public async onOffline(): Promise<void> {
window.log.info('SocketManager.onOffline');
this.isOffline = true;
this.authenticated?.abort();
this.unauthenticated?.abort();
}
//
// Private
//
private async getUnauthenticatedResource(): Promise<WebSocketResource> {
if (this.isOffline) {
throw new HTTPError('SocketManager offline', {
code: 0,
headers: {},
stack: new Error().stack,
});
}
if (this.unauthenticated) {
return this.unauthenticated.getResult();
}
window.log.info('SocketManager: connecting unauthenticated socket');
const process = this.connectResource({
path: '/v1/websocket/',
resourceOptions: {
keepalive: { path: '/v1/keepalive' },
},
});
this.unauthenticated = process;
const unauthenticated = await this.unauthenticated.getResult();
window.log.info('SocketManager: connected unauthenticated socket');
let timer: NodeJS.Timeout | undefined = setTimeout(() => {
window.log.info(
'SocketManager: shutting down unauthenticated socket after timeout'
);
timer = undefined;
unauthenticated.shutdown();
this.dropUnauthenticated(process);
}, FIVE_MINUTES);
unauthenticated.addEventListener('close', ({ code, reason }): void => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
if (this.unauthenticated !== process) {
return;
}
window.log.warn(
'SocketManager: unauthenticated socket closed ' +
`with code=${code} and reason=${reason}`
);
this.dropUnauthenticated(process);
});
return this.unauthenticated.getResult();
}
private connectResource({
path,
resourceOptions,
query = {},
timeout = TEN_SECONDS,
}: {
path: string;
resourceOptions: WebSocketResourceOptions;
query?: Record<string, string>;
timeout?: number;
}): AbortableProcess<WebSocketResource> {
const fixedScheme = this.options.url
.replace('https://', 'wss://')
.replace('http://', 'ws://');
const headers = {
'User-Agent': getUserAgent(this.options.version),
};
const client = new WebSocketClient({
tlsOptions: {
ca: this.options.certificateAuthority,
agent: this.proxyAgent,
},
maxReceivedFrameSize: 0x210000,
});
const queryWithDefaults = {
agent: 'OWD',
version: this.options.version,
...query,
};
client.connect(
`${fixedScheme}${path}?${qs.encode(queryWithDefaults)}`,
undefined,
undefined,
headers
);
const { stack } = new Error();
const { promise, resolve, reject } = explodePromise<WebSocketResource>();
const timer = setTimeout(() => {
reject(new ConnectTimeoutError('Connection timed out'));
client.abort();
}, timeout);
let resource: WebSocketResource | undefined;
client.on('connect', socket => {
clearTimeout(timer);
resource = new WebSocketResource(socket, resourceOptions);
resolve(resource);
});
client.on('httpResponse', async response => {
clearTimeout(timer);
const statusCode = response.statusCode || -1;
await handleStatusCode(statusCode);
const error = new HTTPError(
'connectResource: invalid websocket response',
{
code: statusCode || -1,
headers: {},
stack,
}
);
const translatedError = translateError(error);
strictAssert(
translatedError,
'`httpResponse` event cannot be emitted with 200 status code'
);
reject(translatedError);
});
client.on('connectFailed', e => {
clearTimeout(timer);
reject(
new HTTPError('connectResource: connectFailed', {
code: -1,
headers: {},
response: e.toString(),
stack,
})
);
});
return new AbortableProcess<WebSocketResource>(
`SocketManager.connectResource(${path})`,
{
abort() {
if (resource) {
resource.close(3000, 'aborted');
} else {
clearTimeout(timer);
client.abort();
}
},
},
promise
);
}
private static async checkResource(
process?: AbortableProcess<WebSocketResource>
): Promise<void> {
if (!process) {
return;
}
const resource = await process.getResult();
resource.forceKeepAlive();
}
private dropAuthenticated(
process: AbortableProcess<WebSocketResource>
): void {
strictAssert(
this.authenticated === process,
'Authenticated resource mismatch'
);
this.incomingRequestQueue = [];
this.authenticated = undefined;
this.status = SocketStatus.CLOSED;
}
private dropUnauthenticated(
process: AbortableProcess<WebSocketResource>
): void {
strictAssert(
this.unauthenticated === process,
'Unauthenticated resource mismatch'
);
this.unauthenticated = undefined;
}
private queueOrHandleRequest(req: IncomingWebSocketRequest): void {
if (this.requestHandlers.size === 0) {
this.incomingRequestQueue.push(req);
window.log.info(
'SocketManager: request handler unavailable, ' +
`queued request. Queue size: ${this.incomingRequestQueue.length}`
);
return;
}
for (const handlers of this.requestHandlers) {
try {
handlers.handleRequest(req);
} catch (error) {
window.log.warn(
'SocketManager: got exception while handling incoming request, ' +
`error: ${Errors.toLogFormat(error)}`
);
}
}
}
// EventEmitter types
public on(type: 'authError', callback: (error: HTTPError) => void): this;
public on(
type: string | symbol,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listener: (...args: Array<any>) => void
): this {
return super.on(type, listener);
}
public emit(type: 'authError', error: HTTPError): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public emit(type: string | symbol, ...args: Array<any>): boolean {
return super.emit(type, ...args);
}
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { SignalService as Proto } from '../protobuf'; import type { SignalService as Proto } from '../protobuf';
import type { IncomingWebSocketRequest } from './WebsocketResources';
export { export {
IdentityKeyType, IdentityKeyType,
@ -232,3 +233,7 @@ export interface CallbackResultType {
timestamp?: number; timestamp?: number;
recipients?: Record<string, Array<number>>; recipients?: Record<string, Array<number>>;
} }
export interface IRequestHandler {
handleRequest(request: IncomingWebSocketRequest): void;
}

46
ts/textsecure/Utils.ts Normal file
View file

@ -0,0 +1,46 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export async function handleStatusCode(status: number): Promise<void> {
if (status === 499) {
window.log.error('Got 499 from Signal Server. Build is expired.');
await window.storage.put('remoteBuildExpiration', Date.now());
window.reduxActions.expiration.hydrateExpirationStatus(true);
}
}
export function translateError(error: Error): Error | undefined {
const { code } = error;
if (code === 200) {
// Happens sometimes when we get no response. Might be nice to get 204 instead.
return undefined;
}
let message: string;
switch (code) {
case -1:
message =
'Failed to connect to the server, please check your network connection.';
break;
case 413:
message = 'Rate limit exceeded, please try again later.';
break;
case 403:
message = 'Invalid code, please try again.';
break;
case 417:
message = 'Number already registered.';
break;
case 401:
message =
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
break;
case 404:
message = 'Number is not registered.';
break;
default:
message = 'The server rejected our query, please file a bug report.';
}
// eslint-disable-next-line no-param-reassign
error.message = `${message} (original: ${error.message})`;
return error;
}

View file

@ -11,7 +11,7 @@
import fetch, { Response } from 'node-fetch'; import fetch, { Response } from 'node-fetch';
import ProxyAgent from 'proxy-agent'; import ProxyAgent from 'proxy-agent';
import { Agent, RequestOptions } from 'https'; import { Agent } from 'https';
import pProps from 'p-props'; import pProps from 'p-props';
import { import {
compact, compact,
@ -26,13 +26,13 @@ import { pki } from 'node-forge';
import is from '@sindresorhus/is'; import is from '@sindresorhus/is';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
import { client as WebSocketClient, connection as WebSocket } from 'websocket';
import { z } from 'zod'; import { z } from 'zod';
import Long from 'long'; import Long from 'long';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
import { getUserAgent } from '../util/getUserAgent'; import { getUserAgent } from '../util/getUserAgent';
import { toWebSafeBase64 } from '../util/webSafeBase64'; import { toWebSafeBase64 } from '../util/webSafeBase64';
import { SocketStatus } from '../types/SocketStatus';
import { isPackIdValid, redactPackId } from '../types/Stickers'; import { isPackIdValid, redactPackId } from '../types/Stickers';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { import {
@ -55,11 +55,14 @@ import {
StorageServiceCallOptionsType, StorageServiceCallOptionsType,
StorageServiceCredentials, StorageServiceCredentials,
} from '../textsecure.d'; } from '../textsecure.d';
import { SocketManager } from './SocketManager';
import WebSocketResource from './WebsocketResources';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import { ConnectTimeoutError } from './Errors'; import { HTTPError } from './Errors';
import MessageSender from './SendMessage'; import MessageSender from './SendMessage';
import { WebAPICredentials } from './Types.d'; import { WebAPICredentials, IRequestHandler } from './Types.d';
import { handleStatusCode, translateError } from './Utils';
// TODO: remove once we move away from ArrayBuffers // TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array; const FIXMEU8 = Uint8Array;
@ -263,96 +266,6 @@ function _validateResponse(response: any, schema: any) {
return true; return true;
} }
export type ConnectSocketOptions = Readonly<{
certificateAuthority: string;
proxyUrl?: string;
version: string;
timeout?: number;
}>;
const TEN_SECONDS = 1000 * 10;
async function _connectSocket(
url: string,
{
certificateAuthority,
proxyUrl,
version,
timeout = TEN_SECONDS,
}: ConnectSocketOptions
): Promise<WebSocket> {
let tlsOptions: RequestOptions = {
ca: certificateAuthority,
};
if (proxyUrl) {
tlsOptions = {
...tlsOptions,
agent: new ProxyAgent(proxyUrl),
};
}
const headers = {
'User-Agent': getUserAgent(version),
};
const client = new WebSocketClient({
tlsOptions,
maxReceivedFrameSize: 0x210000,
});
client.connect(url, undefined, undefined, headers);
const { stack } = new Error();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new ConnectTimeoutError('Connection timed out'));
client.abort();
}, timeout);
client.on('connect', socket => {
clearTimeout(timer);
resolve(socket);
});
client.on('httpResponse', async response => {
clearTimeout(timer);
const statusCode = response.statusCode || -1;
await _handleStatusCode(statusCode);
const error = makeHTTPError(
'_connectSocket: invalid websocket response',
statusCode || -1,
{}, // headers
undefined,
stack
);
const translatedError = _translateError(error);
assert(
translatedError,
'`httpResponse` event cannot be emitted with 200 status code'
);
reject(translatedError);
});
client.on('connectFailed', e => {
clearTimeout(timer);
reject(
makeHTTPError(
'_connectSocket: connectFailed',
-1,
{},
e.toString(),
stack
)
);
});
});
}
const FIVE_MINUTES = 1000 * 60 * 5; const FIVE_MINUTES = 1000 * 60 * 5;
type AgentCacheType = { type AgentCacheType = {
@ -378,6 +291,7 @@ type HTTPCodeType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type RedactUrl = (url: string) => string; type RedactUrl = (url: string) => string;
type PromiseAjaxOptionsType = { type PromiseAjaxOptionsType = {
socketManager?: SocketManager;
accessKey?: string; accessKey?: string;
basicAuth?: string; basicAuth?: string;
certificateAuthority?: string; certificateAuthority?: string;
@ -468,76 +382,28 @@ function getHostname(url: string): string {
return urlObject.hostname; return urlObject.hostname;
} }
async function _handleStatusCode(
status: number,
unauthenticated = false
): Promise<void> {
if (status === 499) {
window.log.error('Got 499 from Signal Server. Build is expired.');
await window.storage.put('remoteBuildExpiration', Date.now());
window.reduxActions.expiration.hydrateExpirationStatus(true);
}
if (!unauthenticated && status === 401) {
window.log.error('Got 401 from Signal Server. We might be unlinked.');
window.Whisper.events.trigger('mightBeUnlinked');
}
}
function _translateError(error: Error): Error | undefined {
const { code } = error;
if (code === 200) {
// Happens sometimes when we get no response. Might be nice to get 204 instead.
return undefined;
}
let message: string;
switch (code) {
case -1:
message =
'Failed to connect to the server, please check your network connection.';
break;
case 413:
message = 'Rate limit exceeded, please try again later.';
break;
case 403:
message = 'Invalid code, please try again.';
break;
case 417:
message = 'Number already registered.';
break;
case 401:
message =
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
break;
case 404:
message = 'Number is not registered.';
break;
default:
message = 'The server rejected our query, please file a bug report.';
}
error.message = `${message} (original: ${error.message})`;
return error;
}
async function _promiseAjax( async function _promiseAjax(
providedUrl: string | null, providedUrl: string | null,
options: PromiseAjaxOptionsType options: PromiseAjaxOptionsType
): Promise<any> { ): Promise<
return new Promise((resolve, reject) => { | string
| ArrayBuffer
| unknown
| JSONWithDetailsType
| ArrayBufferWithDetailsType
> {
const url = providedUrl || `${options.host}/${options.path}`; const url = providedUrl || `${options.host}/${options.path}`;
const unauthLabel = options.unauthenticated ? ' (unauth)' : ''; const unauthLabel = options.unauthenticated ? ' (unauth)' : '';
if (options.redactUrl) { if (options.redactUrl) {
window.log.info( window.log.info(`${options.type} ${options.redactUrl(url)}${unauthLabel}`);
`${options.type} ${options.redactUrl(url)}${unauthLabel}`
);
} else { } else {
window.log.info(`${options.type} ${url}${unauthLabel}`); window.log.info(`${options.type} ${url}${unauthLabel}`);
} }
const timeout = const timeout = typeof options.timeout === 'number' ? options.timeout : 10000;
typeof options.timeout === 'number' ? options.timeout : 10000;
const { proxyUrl } = options; const { proxyUrl, socketManager } = options;
const agentType = options.unauthenticated ? 'unauth' : 'auth'; const agentType = options.unauthenticated ? 'unauth' : 'auth';
const cacheKey = `${proxyUrl}-${agentType}`; const cacheKey = `${proxyUrl}-${agentType}`;
@ -600,18 +466,27 @@ async function _promiseAjax(
fetchOptions.headers['Content-Type'] = options.contentType; fetchOptions.headers['Content-Type'] = options.contentType;
} }
fetch(url, fetchOptions) let response: Response;
.then(async response => { let result: string | ArrayBuffer | unknown;
try {
response = socketManager
? await socketManager.fetch(url, fetchOptions)
: await fetch(url, fetchOptions);
if ( if (
options.serverUrl && options.serverUrl &&
getHostname(options.serverUrl) === getHostname(url) getHostname(options.serverUrl) === getHostname(url)
) { ) {
await _handleStatusCode(response.status, unauthenticated); await handleStatusCode(response.status);
if (!unauthenticated && response.status === 401) {
window.log.error('Got 401 from Signal Server. We might be unlinked.');
window.Whisper.events.trigger('mightBeUnlinked');
}
} }
let resultPromise;
if (DEBUG && !isSuccess(response.status)) { if (DEBUG && !isSuccess(response.status)) {
resultPromise = response.text(); result = await response.text();
} else if ( } else if (
(options.responseType === 'json' || (options.responseType === 'json' ||
options.responseType === 'jsonwithdetails') && options.responseType === 'jsonwithdetails') &&
@ -619,27 +494,46 @@ async function _promiseAjax(
response.headers.get('Content-Type') || '' response.headers.get('Content-Type') || ''
) )
) { ) {
resultPromise = response.json(); result = await response.json();
} else if ( } else if (
options.responseType === 'arraybuffer' || options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails' options.responseType === 'arraybufferwithdetails'
) { ) {
resultPromise = response.buffer(); result = await response.arrayBuffer();
} else { } else {
resultPromise = response.textConverted(); result = await response.textConverted();
}
} catch (e) {
if (options.redactUrl) {
window.log.error(options.type, options.redactUrl(url), 0, 'Error');
} else {
window.log.error(options.type, url, 0, 'Error');
}
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
throw makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack);
} }
return resultPromise.then(result => { if (!isSuccess(response.status)) {
if (isSuccess(response.status)) { if (options.redactUrl) {
if ( window.log.info(
options.responseType === 'arraybuffer' || options.type,
options.responseType === 'arraybufferwithdetails' options.redactUrl(url),
) { response.status,
result = result.buffer.slice( 'Error'
result.byteOffset, );
result.byteOffset + result.byteLength } else {
window.log.error(options.type, url, response.status, 'Error');
}
throw makeHTTPError(
'promiseAjax: error response',
response.status,
response.headers.raw(),
result,
options.stack
); );
} }
if ( if (
options.responseType === 'json' || options.responseType === 'json' ||
options.responseType === 'jsonwithdetails' options.responseType === 'jsonwithdetails'
@ -654,24 +548,15 @@ async function _promiseAjax(
'Error' 'Error'
); );
} else { } else {
window.log.error( window.log.error(options.type, url, response.status, 'Error');
options.type,
url,
response.status,
'Error'
);
} }
reject( throw makeHTTPError(
makeHTTPError(
'promiseAjax: invalid response', 'promiseAjax: invalid response',
response.status, response.status,
response.headers.raw(), response.headers.raw(),
result, result,
options.stack options.stack
)
); );
return;
} }
} }
} }
@ -686,17 +571,18 @@ async function _promiseAjax(
} else { } else {
window.log.info(options.type, url, response.status, 'Success'); window.log.info(options.type, url, response.status, 'Success');
} }
if (options.responseType === 'arraybufferwithdetails') { if (options.responseType === 'arraybufferwithdetails') {
assert(result instanceof ArrayBuffer, 'Expected ArrayBuffer result');
const fullResult: ArrayBufferWithDetailsType = { const fullResult: ArrayBufferWithDetailsType = {
data: result, data: result,
contentType: getContentType(response), contentType: getContentType(response),
response, response,
}; };
resolve(fullResult); return fullResult;
return;
} }
if (options.responseType === 'jsonwithdetails') { if (options.responseType === 'jsonwithdetails') {
const fullResult: JSONWithDetailsType = { const fullResult: JSONWithDetailsType = {
data: result, data: result,
@ -704,48 +590,10 @@ async function _promiseAjax(
response, response,
}; };
resolve(fullResult); return fullResult;
return;
} }
resolve(result); return result;
return;
}
if (options.redactUrl) {
window.log.info(
options.type,
options.redactUrl(url),
response.status,
'Error'
);
} else {
window.log.error(options.type, url, response.status, 'Error');
}
reject(
makeHTTPError(
'promiseAjax: error response',
response.status,
response.headers.raw(),
result,
options.stack
)
);
});
})
.catch(e => {
if (options.redactUrl) {
window.log.error(options.type, options.redactUrl(url), 0, 'Error');
} else {
window.log.error(options.type, url, 0, 'Error');
}
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
reject(makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack));
});
});
} }
async function _retryAjax( async function _retryAjax(
@ -793,21 +641,12 @@ function makeHTTPError(
response: any, response: any,
stack?: string stack?: string
) { ) {
const code = providedCode > 999 || providedCode < 100 ? -1 : providedCode; return new HTTPError(message, {
const e = new Error(`${message}; code: ${code}`); code: providedCode,
e.name = 'HTTPError'; headers,
e.code = code; response,
e.responseHeaders = headers; stack,
if (DEBUG && response) { });
e.stack += `\nresponse: ${response}`;
}
e.stack += `\nOriginal stack:\n${stack}`;
if (response) {
e.response = response;
}
return e;
} }
const URL_CALLS = { const URL_CALLS = {
@ -844,6 +683,21 @@ const URL_CALLS = {
challenge: 'v1/challenge', challenge: 'v1/challenge',
}; };
const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
// MessageController
'messages',
'reportMessage',
// ProfileController
'profile',
// AttachmentControllerV2
'attachmentId',
// RemoteConfigController
'config',
]);
type InitializeOptionsType = { type InitializeOptionsType = {
url: string; url: string;
storageUrl: string; storageUrl: string;
@ -983,7 +837,6 @@ export type WebAPIType = {
deviceId?: number, deviceId?: number,
options?: { accessKey?: string } options?: { accessKey?: string }
) => Promise<ServerKeysType>; ) => Promise<ServerKeysType>;
getMessageSocket: () => Promise<WebSocket>;
getMyKeys: () => Promise<number>; getMyKeys: () => Promise<number>;
getProfile: ( getProfile: (
identifier: string, identifier: string,
@ -1000,7 +853,9 @@ export type WebAPIType = {
profileKeyCredentialRequest?: string; profileKeyCredentialRequest?: string;
} }
) => Promise<any>; ) => Promise<any>;
getProvisioningSocket: () => Promise<WebSocket>; getProvisioningResource: (
handler: IRequestHandler
) => Promise<WebSocketResource>;
getSenderCertificate: ( getSenderCertificate: (
withUuid?: boolean withUuid?: boolean
) => Promise<{ certificate: string }>; ) => Promise<{ certificate: string }>;
@ -1086,6 +941,12 @@ export type WebAPIType = {
Array<{ name: string; enabled: boolean; value: string | null }> Array<{ name: string; enabled: boolean; value: string | null }>
>; >;
authenticate: (credentials: WebAPICredentials) => Promise<void>; authenticate: (credentials: WebAPICredentials) => Promise<void>;
getSocketStatus: () => SocketStatus;
registerRequestHandler: (handler: IRequestHandler) => void;
unregisterRequestHandler: (handler: IRequestHandler) => void;
checkSockets: () => void;
onOnline: () => Promise<void>;
onOffline: () => Promise<void>;
}; };
export type SignedPreKeyType = { export type SignedPreKeyType = {
@ -1200,8 +1061,27 @@ export function initialize({
const PARSE_RANGE_HEADER = /\/(\d+)$/; const PARSE_RANGE_HEADER = /\/(\d+)$/;
const PARSE_GROUP_LOG_RANGE_HEADER = /$versions (\d{1,10})-(\d{1,10})\/(d{1,10})/; const PARSE_GROUP_LOG_RANGE_HEADER = /$versions (\d{1,10})-(\d{1,10})\/(d{1,10})/;
const socketManager = new SocketManager({
url,
certificateAuthority,
version,
proxyUrl,
});
socketManager.on('authError', () => {
window.Whisper.events.trigger('unlinkAndDisconnect');
});
socketManager.authenticate({ username, password });
// Thanks, function hoisting! // Thanks, function hoisting!
return { return {
getSocketStatus,
checkSockets,
onOnline,
onOffline,
registerRequestHandler,
unregisterRequestHandler,
authenticate, authenticate,
confirmCode, confirmCode,
createGroup, createGroup,
@ -1220,11 +1100,10 @@ export function initialize({
getIceServers, getIceServers,
getKeysForIdentifier, getKeysForIdentifier,
getKeysForIdentifierUnauth, getKeysForIdentifierUnauth,
getMessageSocket,
getMyKeys, getMyKeys,
getProfile, getProfile,
getProfileUnauth, getProfileUnauth,
getProvisioningSocket, getProvisioningResource,
getSenderCertificate, getSenderCertificate,
getSticker, getSticker,
getStickerPackManifest, getStickerPackManifest,
@ -1261,7 +1140,10 @@ export function initialize({
param.urlParameters = ''; param.urlParameters = '';
} }
const useWebSocket = WEBSOCKET_CALLS.has(param.call);
return _outerAjax(null, { return _outerAjax(null, {
socketManager: useWebSocket ? socketManager : undefined,
basicAuth: param.basicAuth, basicAuth: param.basicAuth,
certificateAuthority, certificateAuthority,
contentType: param.contentType || 'application/json; charset=utf-8', contentType: param.contentType || 'application/json; charset=utf-8',
@ -1282,7 +1164,7 @@ export function initialize({
unauthenticated: param.unauthenticated, unauthenticated: param.unauthenticated,
accessKey: param.accessKey, accessKey: param.accessKey,
}).catch((e: Error) => { }).catch((e: Error) => {
const translatedError = _translateError(e); const translatedError = translateError(e);
if (translatedError) { if (translatedError) {
throw translatedError; throw translatedError;
} }
@ -1311,6 +1193,33 @@ export function initialize({
}: WebAPICredentials) { }: WebAPICredentials) {
username = newUsername; username = newUsername;
password = newPassword; password = newPassword;
await socketManager.authenticate({ username, password });
}
function getSocketStatus(): SocketStatus {
return socketManager.getStatus();
}
function checkSockets(): void {
// Intentionally not awaiting
socketManager.check();
}
async function onOnline(): Promise<void> {
await socketManager.onOnline();
}
async function onOffline(): Promise<void> {
await socketManager.onOffline();
}
function registerRequestHandler(handler: IRequestHandler): void {
socketManager.registerRequestHandler(handler);
}
function unregisterRequestHandler(handler: IRequestHandler): void {
socketManager.unregisterRequestHandler(handler);
} }
async function getConfig() { async function getConfig() {
@ -1589,8 +1498,7 @@ export function initialize({
const urlPrefix = deviceName ? '/' : '/code/'; const urlPrefix = deviceName ? '/' : '/code/';
// We update our saved username and password, since we're creating a new account // We update our saved username and password, since we're creating a new account
username = number; await authenticate({ username: number, password: newPassword });
password = newPassword;
const response = await _ajax({ const response = await _ajax({
call, call,
@ -1601,7 +1509,10 @@ export function initialize({
}); });
// From here on out, our username will be our UUID or E164 combined with device // From here on out, our username will be our UUID or E164 combined with device
username = `${response.uuid || number}.${response.deviceId || 1}`; await authenticate({
username: `${response.uuid || number}.${response.deviceId || 1}`,
password,
});
return response; return response;
} }
@ -2157,7 +2068,7 @@ export function initialize({
timeout: 0, timeout: 0,
type, type,
version, version,
}); }) as Promise<ArrayBufferWithDetailsType>;
} }
// Groups // Groups
@ -2312,7 +2223,7 @@ export function initialize({
timeout: 0, timeout: 0,
type: 'GET', type: 'GET',
version, version,
}); }) as Promise<ArrayBuffer>;
} }
async function createGroup( async function createGroup(
@ -2461,32 +2372,10 @@ export function initialize({
}; };
} }
function getMessageSocket(): Promise<WebSocket> { function getProvisioningResource(
window.log.info('opening message socket', url); handler: IRequestHandler
const fixedScheme = url ): Promise<WebSocketResource> {
.replace('https://', 'wss://') return socketManager.getProvisioningResource(handler);
.replace('http://', 'ws://');
const login = encodeURIComponent(username);
const pass = encodeURIComponent(password);
const clientVersion = encodeURIComponent(version);
return _connectSocket(
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`,
{ certificateAuthority, proxyUrl, version }
);
}
function getProvisioningSocket(): Promise<WebSocket> {
window.log.info('opening provisioning socket', url);
const fixedScheme = url
.replace('https://', 'wss://')
.replace('http://', 'ws://');
const clientVersion = encodeURIComponent(version);
return _connectSocket(
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`,
{ certificateAuthority, proxyUrl, version }
);
} }
async function getDirectoryAuth(): Promise<{ async function getDirectoryAuth(): Promise<{
@ -2688,7 +2577,7 @@ export function initialize({
const pubKeyBase64 = arrayBufferToBase64(slicedPubKey); const pubKeyBase64 = arrayBufferToBase64(slicedPubKey);
// Do request // Do request
const data = JSON.stringify({ clientPublic: pubKeyBase64 }); const data = JSON.stringify({ clientPublic: pubKeyBase64 });
const result: JSONWithDetailsType = await _outerAjax(null, { const result: JSONWithDetailsType = (await _outerAjax(null, {
certificateAuthority, certificateAuthority,
type: 'PUT', type: 'PUT',
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
@ -2700,7 +2589,7 @@ export function initialize({
data, data,
timeout: 30000, timeout: 30000,
version, version,
}); })) as JSONWithDetailsType;
const { data: responseBody, response } = result; const { data: responseBody, response } = result;
@ -2806,7 +2695,7 @@ export function initialize({
iv: string; iv: string;
data: string; data: string;
mac: string; mac: string;
} = await _outerAjax(null, { } = (await _outerAjax(null, {
certificateAuthority, certificateAuthority,
type: 'PUT', type: 'PUT',
headers: cookie headers: cookie
@ -2823,7 +2712,7 @@ export function initialize({
timeout: 30000, timeout: 30000,
data: JSON.stringify(data), data: JSON.stringify(data),
version, version,
}); })) as any;
// Decode discovery request response // Decode discovery request response
const decodedDiscoveryResponse: { const decodedDiscoveryResponse: {

View file

@ -1,7 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file, no-restricted-syntax */
/* /*
* WebSocket-Resources * WebSocket-Resources
* *
@ -12,12 +12,11 @@
* request.respond(200, 'OK'); * request.respond(200, 'OK');
* }); * });
* *
* client.sendRequest({ * const { response, status } = await client.sendRequest({
* verb: 'PUT', * verb: 'PUT',
* path: '/v1/messages', * path: '/v1/messages',
* body: '{ some: "json" }', * headers: ['content-type:application/json'],
* success: function(message, status, request) {...}, * body: Buffer.from('{ some: "json" }'),
* error: function(message, status, request) {...}
* }); * });
* *
* 1. https://github.com/signalapp/WebSocket-Resources * 1. https://github.com/signalapp/WebSocket-Resources
@ -32,13 +31,10 @@ import { dropNull } from '../util/dropNull';
import { isOlderThan } from '../util/timestamp'; import { isOlderThan } from '../util/timestamp';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { normalizeNumber } from '../util/normalizeNumber'; import { normalizeNumber } from '../util/normalizeNumber';
import * as Errors from '../types/errors';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
type Callback = ( const THIRTY_SECONDS = 30 * 1000;
message: string,
status: number,
request: OutgoingWebSocketRequest
) => void;
export class IncomingWebSocketRequest { export class IncomingWebSocketRequest {
private readonly id: Long | number; private readonly id: Long | number;
@ -53,7 +49,7 @@ export class IncomingWebSocketRequest {
constructor( constructor(
request: Proto.IWebSocketRequestMessage, request: Proto.IWebSocketRequestMessage,
private readonly socket: WebSocket private readonly sendBytes: (bytes: Buffer) => void
) { ) {
strictAssert(request.id, 'request without id'); strictAssert(request.id, 'request without id');
strictAssert(request.verb, 'request without verb'); strictAssert(request.verb, 'request without verb');
@ -64,7 +60,6 @@ export class IncomingWebSocketRequest {
this.path = request.path; this.path = request.path;
this.body = dropNull(request.body); this.body = dropNull(request.body);
this.headers = request.headers || []; this.headers = request.headers || [];
this.socket = socket;
} }
public respond(status: number, message: string): void { public respond(status: number, message: string): void {
@ -73,47 +68,24 @@ export class IncomingWebSocketRequest {
response: { id: this.id, message, status }, response: { id: this.id, message, status },
}).finish(); }).finish();
this.socket.sendBytes(Buffer.from(bytes)); this.sendBytes(Buffer.from(bytes));
} }
} }
export type OutgoingWebSocketRequestOptions = Readonly<{ export type SendRequestOptions = Readonly<{
verb: string; verb: string;
path: string; path: string;
body?: Uint8Array; body?: Uint8Array;
timeout?: number;
headers?: ReadonlyArray<string>; headers?: ReadonlyArray<string>;
error?: Callback;
success?: Callback;
}>; }>;
export class OutgoingWebSocketRequest { export type SendRequestResult = Readonly<{
public readonly error: Callback | undefined; status: number;
message: string;
public readonly success: Callback | undefined; response?: Uint8Array;
headers: ReadonlyArray<string>;
public response: Proto.IWebSocketResponseMessage | undefined; }>;
constructor(
id: number,
options: OutgoingWebSocketRequestOptions,
socket: WebSocket
) {
this.error = options.error;
this.success = options.success;
const bytes = Proto.WebSocketMessage.encode({
type: Proto.WebSocketMessage.Type.REQUEST,
request: {
verb: options.verb,
path: options.path,
body: options.body,
headers: options.headers ? options.headers.slice() : undefined,
id,
},
}).finish();
socket.sendBytes(Buffer.from(bytes));
}
}
export type WebSocketResourceOptions = { export type WebSocketResourceOptions = {
handleRequest?: (request: IncomingWebSocketRequest) => void; handleRequest?: (request: IncomingWebSocketRequest) => void;
@ -129,12 +101,21 @@ export class CloseEvent extends Event {
export default class WebSocketResource extends EventTarget { export default class WebSocketResource extends EventTarget {
private outgoingId = 1; private outgoingId = 1;
private closed?: boolean; private closed = false;
private readonly outgoingMap = new Map<number, OutgoingWebSocketRequest>(); private readonly outgoingMap = new Map<
number,
(result: SendRequestResult) => void
>();
private readonly boundOnMessage: (message: IMessage) => void; private readonly boundOnMessage: (message: IMessage) => void;
private activeRequests = new Set<IncomingWebSocketRequest | number>();
private shuttingDown = false;
private shutdownTimer?: NodeJS.Timeout;
// Public for tests // Public for tests
public readonly keepalive?: KeepAlive; public readonly keepalive?: KeepAlive;
@ -158,11 +139,22 @@ export default class WebSocketResource extends EventTarget {
keepalive.reset(); keepalive.reset();
socket.on('message', () => keepalive.reset()); socket.on('message', () => keepalive.reset());
socket.on('close', () => keepalive.stop()); socket.on('close', () => keepalive.stop());
socket.on('error', (error: Error) => {
window.log.warn(
'WebSocketResource: WebSocket error',
Errors.toLogFormat(error)
);
});
} }
socket.on('close', () => { socket.on('close', (code, reason) => {
this.closed = true; this.closed = true;
window.log.warn('WebSocketResource: Socket closed');
this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
}); });
this.addEventListener('close', () => this.onClose());
} }
public addEventListener( public addEventListener(
@ -174,19 +166,50 @@ export default class WebSocketResource extends EventTarget {
return super.addEventListener(name, handler); return super.addEventListener(name, handler);
} }
public sendRequest( public async sendRequest(
options: OutgoingWebSocketRequestOptions options: SendRequestOptions
): OutgoingWebSocketRequest { ): Promise<SendRequestResult> {
const id = this.outgoingId; const id = this.outgoingId;
strictAssert(!this.outgoingMap.has(id), 'Duplicate outgoing request'); strictAssert(!this.outgoingMap.has(id), 'Duplicate outgoing request');
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
this.outgoingId = Math.max(1, (this.outgoingId + 1) & 0x7fffffff); this.outgoingId = Math.max(1, (this.outgoingId + 1) & 0x7fffffff);
const outgoing = new OutgoingWebSocketRequest(id, options, this.socket); const bytes = Proto.WebSocketMessage.encode({
this.outgoingMap.set(id, outgoing); type: Proto.WebSocketMessage.Type.REQUEST,
request: {
verb: options.verb,
path: options.path,
body: options.body,
headers: options.headers ? options.headers.slice() : undefined,
id,
},
}).finish();
return outgoing; strictAssert(!this.shuttingDown, 'Cannot send request, shutting down');
this.addActive(id);
const promise = new Promise<SendRequestResult>((resolve, reject) => {
let timer = options.timeout
? setTimeout(() => {
this.removeActive(id);
reject(new Error('Request timed out'));
}, options.timeout)
: undefined;
this.outgoingMap.set(id, result => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
this.removeActive(id);
resolve(result);
});
});
this.socket.sendBytes(Buffer.from(bytes));
return promise;
} }
public forceKeepAlive(): void { public forceKeepAlive(): void {
@ -218,11 +241,37 @@ export default class WebSocketResource extends EventTarget {
return; return;
} }
window.log.warn('Dispatching our own socket close event'); window.log.warn(
'WebSocketResource: Dispatching our own socket close event'
);
this.dispatchEvent(new CloseEvent(code, reason || 'normal')); this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
}, 5000); }, 5000);
} }
public shutdown(): void {
if (this.closed) {
return;
}
if (this.activeRequests.size === 0) {
window.log.info('WebSocketResource: no active requests, closing');
this.close(3000, 'Shutdown');
return;
}
this.shuttingDown = true;
window.log.info('WebSocketResource: shutting down');
this.shutdownTimer = setTimeout(() => {
if (this.closed) {
return;
}
window.log.warn('WebSocketResource: Failed to shutdown gracefully');
this.close(3000, 'Shutdown');
}, THIRTY_SECONDS);
}
private onMessage({ type, binaryData }: IMessage): void { private onMessage({ type, binaryData }: IMessage): void {
if (type !== 'binary' || !binaryData) { if (type !== 'binary' || !binaryData) {
throw new Error(`Unsupported websocket message type: ${type}`); throw new Error(`Unsupported websocket message type: ${type}`);
@ -236,7 +285,23 @@ export default class WebSocketResource extends EventTarget {
const handleRequest = const handleRequest =
this.options.handleRequest || this.options.handleRequest ||
(request => request.respond(404, 'Not found')); (request => request.respond(404, 'Not found'));
handleRequest(new IncomingWebSocketRequest(message.request, this.socket));
const incomingRequest = new IncomingWebSocketRequest(
message.request,
(bytes: Buffer): void => {
this.removeActive(incomingRequest);
this.socket.sendBytes(bytes);
}
);
if (this.shuttingDown) {
incomingRequest.respond(500, 'Shutting down');
return;
}
this.addActive(incomingRequest);
handleRequest(incomingRequest);
} else if ( } else if (
message.type === Proto.WebSocketMessage.Type.RESPONSE && message.type === Proto.WebSocketMessage.Type.RESPONSE &&
message.response message.response
@ -245,26 +310,61 @@ export default class WebSocketResource extends EventTarget {
strictAssert(response.id, 'response without id'); strictAssert(response.id, 'response without id');
const responseId = normalizeNumber(response.id); const responseId = normalizeNumber(response.id);
const request = this.outgoingMap.get(responseId); const resolve = this.outgoingMap.get(responseId);
this.outgoingMap.delete(responseId); this.outgoingMap.delete(responseId);
if (!request) { if (!resolve) {
throw new Error(`Received response for unknown request ${responseId}`); throw new Error(`Received response for unknown request ${responseId}`);
} }
request.response = dropNull(response); resolve({
status: response.status ?? -1,
let callback = request.error; message: response.message ?? '',
response: dropNull(response.body),
const status = response.status ?? -1; headers: response.headers ?? [],
if (status >= 200 && status < 300) { });
callback = request.success;
}
if (typeof callback === 'function') {
callback(response.message ?? '', status, request);
} }
} }
private onClose(): void {
const outgoing = new Map(this.outgoingMap);
this.outgoingMap.clear();
for (const resolve of outgoing.values()) {
resolve({
status: 500,
message: 'Connection closed',
response: undefined,
headers: [],
});
}
}
private addActive(request: IncomingWebSocketRequest | number): void {
this.activeRequests.add(request);
}
private removeActive(request: IncomingWebSocketRequest | number): void {
if (!this.activeRequests.has(request)) {
window.log.warn('WebSocketResource: removing unknown request');
return;
}
this.activeRequests.delete(request);
if (this.activeRequests.size !== 0) {
return;
}
if (!this.shuttingDown) {
return;
}
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = undefined;
}
window.log.info('WebSocketResource: shutdown complete');
this.close(3000, 'Shutdown');
} }
} }
@ -307,7 +407,7 @@ class KeepAlive {
this.clearTimers(); this.clearTimers();
} }
public send(): void { public async send(): Promise<void> {
this.clearTimers(); this.clearTimers();
if (isOlderThan(this.lastAliveAt, MAX_KEEPALIVE_INTERVAL_MS)) { if (isOlderThan(this.lastAliveAt, MAX_KEEPALIVE_INTERVAL_MS)) {
@ -332,11 +432,14 @@ class KeepAlive {
} }
window.log.info('WebSocketResources: Sending a keepalive message'); window.log.info('WebSocketResources: Sending a keepalive message');
this.wsr.sendRequest({ const { status } = await this.wsr.sendRequest({
verb: 'GET', verb: 'GET',
path: this.path, path: this.path,
success: this.reset.bind(this),
}); });
if (status >= 200 || status < 300) {
this.reset();
}
} }
public reset(): void { public reset(): void {

View file

@ -0,0 +1,61 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, omit } from 'lodash';
import { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull';
import { DownloadedAttachmentType } from '../types/Attachment';
import * as MIME from '../types/MIME';
import * as Bytes from '../Bytes';
import { typedArrayToArrayBuffer } from '../Crypto';
import Crypto from './Crypto';
import { ProcessedAttachment } from './Types.d';
import type { WebAPIType } from './WebAPI';
export async function downloadAttachment(
server: WebAPIType,
attachment: ProcessedAttachment
): Promise<DownloadedAttachmentType> {
const cdnId = attachment.cdnId || attachment.cdnKey;
const { cdnNumber } = attachment;
if (!cdnId) {
throw new Error('downloadAttachment: Attachment was missing cdnId!');
}
strictAssert(cdnId, 'attachment without cdnId');
const encrypted = await server.getAttachment(cdnId, dropNull(cdnNumber));
const { key, digest, size, contentType } = attachment;
if (!digest) {
throw new Error('Failure: Ask sender to update Signal and resend.');
}
strictAssert(key, 'attachment has no key');
strictAssert(digest, 'attachment has no digest');
const paddedData = await Crypto.decryptAttachment(
encrypted,
typedArrayToArrayBuffer(Bytes.fromBase64(key)),
typedArrayToArrayBuffer(Bytes.fromBase64(digest))
);
if (!isNumber(size)) {
throw new Error(
`downloadAttachment: Size was not provided, actual size was ${paddedData.byteLength}`
);
}
const data = window.Signal.Crypto.getFirstBytes(paddedData, size);
return {
...omit(attachment, 'digest', 'key'),
contentType: contentType
? MIME.fromString(contentType)
: MIME.APPLICATION_OCTET_STREAM,
data,
};
}

View file

@ -7,7 +7,6 @@ import MessageReceiver from './MessageReceiver';
import utils from './Helpers'; import utils from './Helpers';
import Crypto from './Crypto'; import Crypto from './Crypto';
import { ContactBuffer, GroupBuffer } from './ContactsParser'; import { ContactBuffer, GroupBuffer } from './ContactsParser';
import createTaskWithTimeout from './TaskWithTimeout';
import SyncRequest from './SyncRequest'; import SyncRequest from './SyncRequest';
import MessageSender from './SendMessage'; import MessageSender from './SendMessage';
import StringView from './StringView'; import StringView from './StringView';
@ -16,7 +15,6 @@ import * as WebAPI from './WebAPI';
import WebSocketResource from './WebsocketResources'; import WebSocketResource from './WebsocketResources';
export const textsecure = { export const textsecure = {
createTaskWithTimeout,
crypto: Crypto, crypto: Crypto,
utils, utils,
storage: new Storage(), storage: new Storage(),

View file

@ -81,12 +81,16 @@ export class User extends EventEmitter {
? this.storage.put('device_name', deviceName) ? this.storage.put('device_name', deviceName)
: Promise.resolve(), : Promise.resolve(),
]); ]);
}
window.log.info('storage.user: credentials changed'); public emitCredentialsChanged(reason: string): void {
window.log.info(`storage.user: credentials changed, ${reason}`);
this.emit('credentialsChange'); this.emit('credentialsChange');
} }
public async removeCredentials(): Promise<void> { public async removeCredentials(): Promise<void> {
window.log.info('storage.user: removeCredentials');
await Promise.all([ await Promise.all([
this.storage.remove('number_id'), this.storage.remove('number_id'),
this.storage.remove('uuid_id'), this.storage.remove('uuid_id'),

View file

@ -0,0 +1,38 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable no-restricted-syntax */
import { explodePromise } from './explodePromise';
export interface IController {
abort(): void;
}
export class AbortableProcess<Result> implements IController {
private abortReject: (error: Error) => void;
public readonly resultPromise: Promise<Result>;
constructor(
private readonly name: string,
private readonly controller: IController,
resultPromise: Promise<Result>
) {
const {
promise: abortPromise,
reject: abortReject,
} = explodePromise<Result>();
this.abortReject = abortReject;
this.resultPromise = Promise.race([abortPromise, resultPromise]);
}
public abort(): void {
this.controller.abort();
this.abortReject(new Error(`Process "${this.name}" was aborted`));
}
public getResult(): Promise<Result> {
return this.resultPromise;
}
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType, DownloadedAttachmentType } from '../types/Attachment'; import { AttachmentType, DownloadedAttachmentType } from '../types/Attachment';
import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
export async function downloadAttachment( export async function downloadAttachment(
attachmentData: AttachmentType attachmentData: AttachmentType
@ -20,7 +21,8 @@ export async function downloadAttachment(
let downloaded; let downloaded;
try { try {
downloaded = await window.textsecure.messageReceiver.downloadAttachment( downloaded = await doDownloadAttachment(
window.textsecure.server,
migratedAttachment migratedAttachment
); );
} catch (error) { } catch (error) {

25
ts/util/explodePromise.ts Normal file
View file

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function explodePromise<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
} {
let resolve: (value: T) => void;
let reject: (error: Error) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return {
promise,
// Typescript thinks that resolve and reject can be undefined here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolve: resolve!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
reject: reject!,
};
}

View file

@ -19,10 +19,12 @@ declare global {
window.waitBatchers = []; window.waitBatchers = [];
window.flushAllWaitBatchers = async () => { window.flushAllWaitBatchers = async () => {
window.log.info('waitBatcher#flushAllWaitBatchers');
await Promise.all(window.waitBatchers.map(item => item.flushAndWait())); await Promise.all(window.waitBatchers.map(item => item.flushAndWait()));
}; };
window.waitForAllWaitBatchers = async () => { window.waitForAllWaitBatchers = async () => {
window.log.info('waitBatcher#waitForAllWaitBatchers');
await Promise.all(window.waitBatchers.map(item => item.onIdle())); await Promise.all(window.waitBatchers.map(item => item.onIdle()));
}; };

2
ts/window.d.ts vendored
View file

@ -483,7 +483,7 @@ declare global {
getServerTrustRoot: () => WhatIsThis; getServerTrustRoot: () => WhatIsThis;
readyForUpdates: () => void; readyForUpdates: () => void;
logAppLoadedEvent: (options: { processedCount?: number }) => void; logAppLoadedEvent: (options: { processedCount?: number }) => void;
logMessageReceiverConnect: () => void; logAuthenticatedConnect: () => void;
// Runtime Flags // Runtime Flags
isShowingModal?: boolean; isShowingModal?: boolean;