From 409bf1fc8274bfe3c44be4bbc7ec3e10c4ba9d23 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 9 Nov 2021 00:32:31 +0100 Subject: [PATCH] WebSocket API for CDS --- app/main.ts | 3 + config/default.json | 3 + package.json | 2 +- preload.js | 3 + sticker-creator/preload.js | 3 + ts/textsecure/CDSSocket.ts | 149 ++++++++++++++++++++++++++++++ ts/textsecure/CDSSocketManager.ts | 82 ++++++++++++++++ ts/textsecure/SendMessage.ts | 9 +- ts/textsecure/SocketManager.ts | 114 +++-------------------- ts/textsecure/WebAPI.ts | 42 ++++++++- ts/textsecure/WebSocket.ts | 127 +++++++++++++++++++++++++ yarn.lock | 8 +- 12 files changed, 436 insertions(+), 109 deletions(-) create mode 100644 ts/textsecure/CDSSocket.ts create mode 100644 ts/textsecure/CDSSocketManager.ts create mode 100644 ts/textsecure/WebSocket.ts diff --git a/app/main.ts b/app/main.ts index fde51e5ed..f023feddd 100644 --- a/app/main.ts +++ b/app/main.ts @@ -296,6 +296,9 @@ function prepareUrl( directoryUrl: config.get('directoryUrl'), directoryEnclaveId: config.get('directoryEnclaveId'), directoryTrustAnchor: config.get('directoryTrustAnchor'), + directoryV2Url: config.get('directoryV2Url'), + directoryV2PublicKey: config.get('directoryV2PublicKey'), + directoryV2CodeHash: config.get('directoryV2CodeHash'), cdnUrl0: config.get('cdn').get('0'), cdnUrl2: config.get('cdn').get('2'), certificateAuthority: config.get('certificateAuthority'), diff --git a/config/default.json b/config/default.json index 7f23067ef..e477f8d94 100644 --- a/config/default.json +++ b/config/default.json @@ -4,6 +4,9 @@ "directoryUrl": "https://api-staging.directory.signal.org", "directoryEnclaveId": "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15", "directoryTrustAnchor": "-----BEGIN CERTIFICATE-----\nMIIFSzCCA7OgAwIBAgIJANEHdl0yo7CUMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV\nBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV\nBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0\nYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwIBcNMTYxMTE0MTUzNzMxWhgPMjA0OTEy\nMzEyMzU5NTlaMH4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwL\nU2FudGEgQ2xhcmExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQD\nDCdJbnRlbCBTR1ggQXR0ZXN0YXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwggGiMA0G\nCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCfPGR+tXc8u1EtJzLA10Feu1Wg+p7e\nLmSRmeaCHbkQ1TF3Nwl3RmpqXkeGzNLd69QUnWovYyVSndEMyYc3sHecGgfinEeh\nrgBJSEdsSJ9FpaFdesjsxqzGRa20PYdnnfWcCTvFoulpbFR4VBuXnnVLVzkUvlXT\nL/TAnd8nIZk0zZkFJ7P5LtePvykkar7LcSQO85wtcQe0R1Raf/sQ6wYKaKmFgCGe\nNpEJUmg4ktal4qgIAxk+QHUxQE42sxViN5mqglB0QJdUot/o9a/V/mMeH8KvOAiQ\nbyinkNndn+Bgk5sSV5DFgF0DffVqmVMblt5p3jPtImzBIH0QQrXJq39AT8cRwP5H\nafuVeLHcDsRp6hol4P+ZFIhu8mmbI1u0hH3W/0C2BuYXB5PC+5izFFh/nP0lc2Lf\n6rELO9LZdnOhpL1ExFOq9H/B8tPQ84T3Sgb4nAifDabNt/zu6MmCGo5U8lwEFtGM\nRoOaX4AS+909x00lYnmtwsDVWv9vBiJCXRsCAwEAAaOByTCBxjBgBgNVHR8EWTBX\nMFWgU6BRhk9odHRwOi8vdHJ1c3RlZHNlcnZpY2VzLmludGVsLmNvbS9jb250ZW50\nL0NSTC9TR1gvQXR0ZXN0YXRpb25SZXBvcnRTaWduaW5nQ0EuY3JsMB0GA1UdDgQW\nBBR4Q3t2pn680K9+QjfrNXw7hwFRPDAfBgNVHSMEGDAWgBR4Q3t2pn680K9+Qjfr\nNXw7hwFRPDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkq\nhkiG9w0BAQsFAAOCAYEAeF8tYMXICvQqeXYQITkV2oLJsp6J4JAqJabHWxYJHGir\nIEqucRiJSSx+HjIJEUVaj8E0QjEud6Y5lNmXlcjqRXaCPOqK0eGRz6hi+ripMtPZ\nsFNaBwLQVV905SDjAzDzNIDnrcnXyB4gcDFCvwDFKKgLRjOB/WAqgscDUoGq5ZVi\nzLUzTqiQPmULAQaB9c6Oti6snEFJiCQ67JLyW/E83/frzCmO5Ru6WjU4tmsmy8Ra\nUd4APK0wZTGtfPXU7w+IBdG5Ez0kE1qzxGQaL4gINJ1zMyleDnbuS8UicjJijvqA\n152Sq049ESDz+1rRGc2NVEqh1KaGXmtXvqxXcTB+Ljy5Bw2ke0v8iGngFBPqCTVB\n3op5KBG3RjbF6RRSzwzuWfL7QErNC8WEy5yDVARzTA5+xmBc388v9Dm21HGfcC8O\nDD+gT9sSpssq0ascmvH49MOgjt1yoysLtdCtJW/9FZpoOypaHx0R+mJTLwPXVMrv\nDaVzWh5aiEx+idkSGMnX\n-----END CERTIFICATE-----\n", + "directoryV2Url": "https://cdsh.staging.signal.org", + "directoryV2PublicKey": "052fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74", + "directoryV2CodeHash": "ec31a51880d19a5e9e0fed404740c1a3ff53a553125564b745acce475f0fded8", "cdn": { "0": "https://cdn-staging.signal.org", "2": "https://cdn2-staging.signal.org" diff --git a/package.json b/package.json index 573ffd624..e9a4dbe84 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "dependencies": { "@popperjs/core": "2.9.2", "@react-spring/web": "9.2.6", - "@signalapp/signal-client": "0.9.5", + "@signalapp/signal-client": "0.9.8", "@sindresorhus/is": "0.8.0", "abort-controller": "3.0.0", "array-move": "2.1.0", diff --git a/preload.js b/preload.js index 095710706..2b8036afe 100644 --- a/preload.js +++ b/preload.js @@ -371,6 +371,9 @@ try { directoryUrl: config.directoryUrl, directoryEnclaveId: config.directoryEnclaveId, directoryTrustAnchor: config.directoryTrustAnchor, + directoryV2Url: config.directoryV2Url, + directoryV2PublicKey: config.directoryV2PublicKey, + directoryV2CodeHash: config.directoryV2CodeHash, cdnUrlObject: { 0: config.cdnUrl0, 2: config.cdnUrl2, diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index 921e031ce..bcc2103f5 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -57,6 +57,9 @@ const WebAPI = initializeWebAPI({ directoryUrl: config.directoryUrl, directoryEnclaveId: config.directoryEnclaveId, directoryTrustAnchor: config.directoryTrustAnchor, + directoryV2Url: config.directoryV2Url, + directoryV2PublicKey: config.directoryV2PublicKey, + directoryV2CodeHash: config.directoryV2CodeHash, cdnUrlObject: { 0: config.cdnUrl0, 2: config.cdnUrl2, diff --git a/ts/textsecure/CDSSocket.ts b/ts/textsecure/CDSSocket.ts new file mode 100644 index 000000000..790a87738 --- /dev/null +++ b/ts/textsecure/CDSSocket.ts @@ -0,0 +1,149 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { EventEmitter } from 'events'; +import type { HsmEnclaveClient } from '@signalapp/signal-client'; +import type { connection as WebSocket } from 'websocket'; +import Long from 'long'; + +import { strictAssert } from '../util/assert'; +import { explodePromise } from '../util/explodePromise'; +import * as durations from '../util/durations'; +import type { UUIDStringType } from '../types/UUID'; +import * as Bytes from '../Bytes'; +import * as Timers from '../Timers'; +import { splitUuids } from '../Crypto'; + +enum State { + Handshake, + Established, + Closed, +} + +const HANDSHAKE_TIMEOUT = 10 * durations.SECOND; +const REQUEST_TIMEOUT = 10 * durations.SECOND; + +export class CDSSocket extends EventEmitter { + private state = State.Handshake; + + private readonly finishedHandshake: Promise; + + private readonly requestQueue = new Array<(buffer: Buffer) => void>(); + + constructor( + private readonly socket: WebSocket, + private readonly enclaveClient: HsmEnclaveClient + ) { + super(); + + const { + promise: finishedHandshake, + resolve, + reject, + } = explodePromise(); + this.finishedHandshake = finishedHandshake; + + const timer = Timers.setTimeout(() => { + reject(new Error('CDS handshake timed out')); + }, HANDSHAKE_TIMEOUT); + + socket.on('message', ({ type, binaryData }) => { + strictAssert(type === 'binary', 'Invalid CDS socket packet'); + strictAssert(binaryData, 'Invalid CDS socket packet'); + + if (this.state === State.Handshake) { + this.enclaveClient.completeHandshake(binaryData); + this.state = State.Established; + Timers.clearTimeout(timer); + resolve(); + return; + } + + const requestHandler = this.requestQueue.shift(); + strictAssert( + requestHandler !== undefined, + 'No handler for incoming CDS data' + ); + + requestHandler(this.enclaveClient.establishedRecv(binaryData)); + }); + socket.on('close', (code, reason) => { + this.state = State.Closed; + this.emit('close', code, reason); + }); + socket.on('error', (error: Error) => this.emit('error', error)); + + socket.sendBytes(this.enclaveClient.initialRequest()); + } + + public close(code: number, reason: string): void { + this.socket.close(code, reason); + } + + public async request({ + e164s, + timeout = REQUEST_TIMEOUT, + }: { + e164s: ReadonlyArray; + timeout?: number; + }): Promise> { + await this.finishedHandshake; + strictAssert( + this.state === State.Established, + 'Connection not established' + ); + + const request = Bytes.concatenate([ + new Uint8Array([0x01]), + ...e164s.map(e164 => { + // Long.fromString handles numbers with or without a leading '+' + return new Uint8Array(Long.fromString(e164).toBytesBE()); + }), + ]); + + const { promise, resolve, reject } = explodePromise(); + + const timer = Timers.setTimeout(() => { + reject(new Error('CDS request timed out')); + }, timeout); + + this.socket.sendBytes( + this.enclaveClient.establishedSend(Buffer.from(request)) + ); + + this.requestQueue.push(resolve); + strictAssert( + this.requestQueue.length === 1, + 'Concurrent use of CDS shold not happen' + ); + const uuids = await promise; + + Timers.clearTimeout(timer); + + return splitUuids(uuids); + } + + // EventEmitter types + + public on( + type: 'close', + callback: (code: number, reason?: string) => void + ): this; + public on(type: 'error', callback: (error: Error) => void): this; + + public on( + type: string | symbol, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: Array) => void + ): this { + return super.on(type, listener); + } + + public emit(type: 'close', code: number, reason?: string): boolean; + public emit(type: 'error', error: Error): boolean; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public emit(type: string | symbol, ...args: Array): boolean { + return super.emit(type, ...args); + } +} diff --git a/ts/textsecure/CDSSocketManager.ts b/ts/textsecure/CDSSocketManager.ts new file mode 100644 index 000000000..55e30bd99 --- /dev/null +++ b/ts/textsecure/CDSSocketManager.ts @@ -0,0 +1,82 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import ProxyAgent from 'proxy-agent'; +import { HsmEnclaveClient, PublicKey } from '@signalapp/signal-client'; +import type { connection as WebSocket } from 'websocket'; + +import * as Bytes from '../Bytes'; +import type { AbortableProcess } from '../util/AbortableProcess'; +import * as log from '../logging/log'; +import type { UUIDStringType } from '../types/UUID'; +import { CDSSocket } from './CDSSocket'; +import { connect as connectWebSocket } from './WebSocket'; + +export type CDSSocketManagerOptionsType = Readonly<{ + url: string; + publicKey: string; + codeHash: string; + certificateAuthority: string; + proxyUrl?: string; + version: string; +}>; + +export class CDSSocketManager { + private readonly publicKey: PublicKey; + + private readonly codeHash: Buffer; + + private readonly proxyAgent?: ReturnType; + + constructor(private readonly options: CDSSocketManagerOptionsType) { + this.publicKey = PublicKey.deserialize( + Buffer.from(Bytes.fromHex(options.publicKey)) + ); + this.codeHash = Buffer.from(Bytes.fromHex(options.codeHash)); + if (options.proxyUrl) { + this.proxyAgent = new ProxyAgent(options.proxyUrl); + } + } + + public async request({ + e164s, + timeout, + }: { + e164s: ReadonlyArray; + timeout?: number; + }): Promise> { + log.info('CDSSocketManager: connecting socket'); + const socket = await this.connect().getResult(); + log.info('CDSSocketManager: connected socket'); + + try { + return await socket.request({ e164s, timeout }); + } finally { + log.info('CDSSocketManager: closing socket'); + socket.close(3000, 'Normal'); + } + } + + private connect(): AbortableProcess { + const enclaveClient = HsmEnclaveClient.new(this.publicKey, [this.codeHash]); + + const { + publicKey: publicKeyHex, + codeHash: codeHashHex, + version, + } = this.options; + + const url = `${this.options.url}/discovery/${publicKeyHex}/${codeHashHex}`; + + return connectWebSocket({ + url, + version, + proxyAgent: this.proxyAgent, + certificateAuthority: this.options.certificateAuthority, + + createResource: (socket: WebSocket): CDSSocket => { + return new CDSSocket(socket, enclaveClient); + }, + }); + } +} diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 62fe1558c..3ec895b69 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -23,6 +23,7 @@ import { SenderKeys } from '../LibSignalStores'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { MIMETypeToString } from '../types/MIME'; import type * as Attachment from '../types/Attachment'; +import type { UUIDStringType } from '../types/UUID'; import type { ChallengeType, GroupCredentialsType, @@ -2058,10 +2059,16 @@ export default class MessageSender { async getUuidsForE164s( numbers: ReadonlyArray - ): Promise> { + ): Promise> { return this.server.getUuidsForE164s(numbers); } + async getUuidsForE164sV2( + numbers: ReadonlyArray + ): Promise> { + return this.server.getUuidsForE164sV2(numbers); + } + async getAvatar(path: string): Promise> { return this.server.getAvatar(path); } diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index fc2be385f..7eec562b1 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -5,21 +5,18 @@ import URL from 'url'; import ProxyAgent from 'proxy-agent'; import type { RequestInit } from 'node-fetch'; import { Response, Headers } from 'node-fetch'; -import { client as WebSocketClient } from 'websocket'; +import type { connection as WebSocket } from 'websocket'; import qs from 'querystring'; import EventListener from 'events'; -import { AbortableProcess } from '../util/AbortableProcess'; +import type { 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 * as durations from '../util/durations'; import { sleep } from '../util/sleep'; import { SocketStatus } from '../types/SocketStatus'; import * as Errors from '../types/errors'; import * as Bytes from '../Bytes'; -import * as Timers from '../Timers'; import * as log from '../logging/log'; import type { @@ -27,11 +24,9 @@ import type { IncomingWebSocketRequest, } from './WebsocketResources'; import WebSocketResource from './WebsocketResources'; -import { ConnectTimeoutError, HTTPError } from './Errors'; -import { handleStatusCode, translateError } from './Utils'; +import { HTTPError } from './Errors'; import type { WebAPICredentials, IRequestHandler } from './Types.d'; - -const TEN_SECONDS = 10 * durations.SECOND; +import { connect as connectWebSocket } from './WebSocket'; const FIVE_MINUTES = 5 * durations.MINUTE; @@ -472,112 +467,29 @@ export class SocketManager extends EventListener { path, resourceOptions, query = {}, - timeout = TEN_SECONDS, }: { path: string; resourceOptions: WebSocketResourceOptions; query?: Record; - timeout?: number; }): AbortableProcess { - 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 url = `${this.options.url}${path}?${qs.encode(queryWithDefaults)}`; - const { stack } = new Error(); + return connectWebSocket({ + url, + certificateAuthority: this.options.certificateAuthority, + version: this.options.version, + proxyAgent: this.proxyAgent, - const { promise, resolve, reject } = explodePromise(); - - const timer = Timers.setTimeout(() => { - reject(new ConnectTimeoutError('Connection timed out')); - - client.abort(); - }, timeout); - - let resource: WebSocketResource | undefined; - client.on('connect', socket => { - Timers.clearTimeout(timer); - - resource = new WebSocketResource(socket, resourceOptions); - resolve(resource); - }); - - client.on('httpResponse', async response => { - Timers.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 => { - Timers.clearTimeout(timer); - - reject( - new HTTPError('connectResource: connectFailed', { - code: -1, - headers: {}, - response: e.toString(), - stack, - }) - ); - }); - - return new AbortableProcess( - `SocketManager.connectResource(${path})`, - { - abort() { - if (resource) { - log.warn(`SocketManager closing socket ${path}`); - resource.close(3000, 'aborted'); - } else { - log.warn(`SocketManager aborting connection ${path}`); - Timers.clearTimeout(timer); - client.abort(); - } - }, + createResource(socket: WebSocket): WebSocketResource { + return new WebSocketResource(socket, resourceOptions); }, - promise - ); + }); } private static async checkResource( diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index a8fd783a3..4ebdc61b7 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -31,6 +31,7 @@ import { toWebSafeBase64 } from '../util/webSafeBase64'; import type { SocketStatus } from '../types/SocketStatus'; import { toLogFormat } from '../types/errors'; import { isPackIdValid, redactPackId } from '../types/Stickers'; +import type { UUIDStringType } from '../types/UUID'; import * as Bytes from '../Bytes'; import { constantTimeEqual, @@ -49,6 +50,7 @@ import type { StorageServiceCredentials, } from '../textsecure.d'; import { SocketManager } from './SocketManager'; +import { CDSSocketManager } from './CDSSocketManager'; import type WebSocketResource from './WebsocketResources'; import { SignalService as Proto } from '../protobuf'; @@ -566,6 +568,9 @@ type InitializeOptionsType = { directoryEnclaveId: string; directoryTrustAnchor: string; directoryUrl: string; + directoryV2Url: string; + directoryV2PublicKey: string; + directoryV2CodeHash: string; cdnUrlObject: { readonly '0': string; readonly [propName: string]: string; @@ -788,7 +793,10 @@ export type WebAPIType = { getStorageRecords: MessageSender['getStorageRecords']; getUuidsForE164s: ( e164s: ReadonlyArray - ) => Promise>; + ) => Promise>; + getUuidsForE164sV2: ( + e164s: ReadonlyArray + ) => Promise>; fetchLinkPreviewMetadata: ( href: string, abortSignal: AbortSignal @@ -925,6 +933,9 @@ export function initialize({ directoryEnclaveId, directoryTrustAnchor, directoryUrl, + directoryV2Url, + directoryV2PublicKey, + directoryV2CodeHash, cdnUrlObject, certificateAuthority, contentProxyUrl, @@ -949,6 +960,15 @@ export function initialize({ if (!is.string(directoryUrl)) { throw new Error('WebAPI.initialize: Invalid directory url'); } + if (!is.string(directoryV2Url)) { + throw new Error('WebAPI.initialize: Invalid directory V2 url'); + } + if (!is.string(directoryV2PublicKey)) { + throw new Error('WebAPI.initialize: Invalid directory V2 public key'); + } + if (!is.string(directoryV2CodeHash)) { + throw new Error('WebAPI.initialize: Invalid directory V2 code hash'); + } if (!is.object(cdnUrlObject)) { throw new Error('WebAPI.initialize: Invalid cdnUrlObject'); } @@ -1009,6 +1029,15 @@ export function initialize({ socketManager.authenticate({ username, password }); } + const cdsSocketManager = new CDSSocketManager({ + url: directoryV2Url, + publicKey: directoryV2PublicKey, + codeHash: directoryV2CodeHash, + certificateAuthority, + version, + proxyUrl, + }); + let fetchForLinkPreviews: linkPreviewFetch.FetchFn; if (proxyUrl) { const agent = new ProxyAgent(proxyUrl); @@ -1057,6 +1086,7 @@ export function initialize({ getStorageManifest, getStorageRecords, getUuidsForE164s, + getUuidsForE164sV2, makeProxiedRequest, makeSfuRequest, modifyGroup, @@ -2758,7 +2788,7 @@ export function initialize({ async function getUuidsForE164s( e164s: ReadonlyArray - ): Promise> { + ): Promise> { const directoryAuth = await getDirectoryAuth(); const attestationResult = await putRemoteAttestation(directoryAuth); @@ -2831,5 +2861,13 @@ export function initialize({ return zipObject(e164s, uuids); } + + async function getUuidsForE164sV2( + e164s: ReadonlyArray + ): Promise> { + const uuids = await cdsSocketManager.request({ e164s }); + + return zipObject(e164s, uuids); + } } } diff --git a/ts/textsecure/WebSocket.ts b/ts/textsecure/WebSocket.ts new file mode 100644 index 000000000..8c10b3e80 --- /dev/null +++ b/ts/textsecure/WebSocket.ts @@ -0,0 +1,127 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type ProxyAgent from 'proxy-agent'; +import { client as WebSocketClient } from 'websocket'; +import type { connection as WebSocket } from 'websocket'; + +import { AbortableProcess } from '../util/AbortableProcess'; +import { strictAssert } from '../util/assert'; +import { explodePromise } from '../util/explodePromise'; +import { getUserAgent } from '../util/getUserAgent'; +import * as durations from '../util/durations'; +import * as log from '../logging/log'; +import * as Timers from '../Timers'; +import { ConnectTimeoutError, HTTPError } from './Errors'; +import { handleStatusCode, translateError } from './Utils'; + +const TEN_SECONDS = 10 * durations.SECOND; + +export type IResource = { + close(code: number, reason: string): void; +}; + +export type ConnectOptionsType = Readonly<{ + url: string; + certificateAuthority: string; + version: string; + proxyAgent?: ReturnType; + timeout?: number; + + createResource(socket: WebSocket): Resource; +}>; + +export function connect({ + url, + certificateAuthority, + version, + proxyAgent, + timeout = TEN_SECONDS, + createResource, +}: ConnectOptionsType): AbortableProcess { + const fixedScheme = url + .replace('https://', 'wss://') + .replace('http://', 'ws://'); + + const headers = { + 'User-Agent': getUserAgent(version), + }; + const client = new WebSocketClient({ + tlsOptions: { + ca: certificateAuthority, + agent: proxyAgent, + }, + maxReceivedFrameSize: 0x210000, + }); + + client.connect(fixedScheme, undefined, undefined, headers); + + const { stack } = new Error(); + + const { promise, resolve, reject } = explodePromise(); + + const timer = Timers.setTimeout(() => { + reject(new ConnectTimeoutError('Connection timed out')); + + client.abort(); + }, timeout); + + let resource: Resource | undefined; + client.on('connect', socket => { + Timers.clearTimeout(timer); + + resource = createResource(socket); + resolve(resource); + }); + + client.on('httpResponse', async response => { + Timers.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 => { + Timers.clearTimeout(timer); + + reject( + new HTTPError('connectResource: connectFailed', { + code: -1, + headers: {}, + response: e.toString(), + stack, + }) + ); + }); + + return new AbortableProcess( + `WebSocket.connect(${url})`, + { + abort() { + if (resource) { + log.warn(`WebSocket: closing socket ${url}`); + resource.close(3000, 'aborted'); + } else { + log.warn(`WebSocket: aborting connection ${url}`); + Timers.clearTimeout(timer); + client.abort(); + } + }, + }, + promise + ); +} diff --git a/yarn.lock b/yarn.lock index ebd6d94bc..f0dcd31d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1717,10 +1717,10 @@ "@react-spring/shared" "~9.2.6-beta.0" "@react-spring/types" "~9.2.6-beta.0" -"@signalapp/signal-client@0.9.5": - version "0.9.5" - resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.9.5.tgz#b133305aa39c00ceae8743316efd0cc0bfe02e2d" - integrity sha512-cLKo6vzrp3MWyTv+LJnX1KGSLS4TUZVZz20NicAuOks926+E5C1xKZEYhD0sTMsbD1CdHLbPqqw8cQy5oXWHag== +"@signalapp/signal-client@0.9.8": + version "0.9.8" + resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.9.8.tgz#e3bd9795eb29ee0246adf0a5245de06054d69180" + integrity sha512-ESm/n2rtVtDVdQvO9ad8y5Hh3grf+HWtM+nzZHYTw3QpvhaYONl3xUBAs1mbGYp/6Gwf83qJ/Le9rduFfw/lVQ== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0"