diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 2d4670da56..8fcb6ed531 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -4343,7 +4343,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see ``` -## attest 0.1.0, device-transfer 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-core 0.1.0, libsignal-ffi 0.40.1, libsignal-jni 0.40.1, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-node 0.40.1, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, poksho 0.7.0, signal-crypto 0.1.0, signal-media 0.1.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, signal-pin 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0 +## attest 0.1.0, device-transfer 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-core 0.1.0, libsignal-ffi 0.41.2, libsignal-jni 0.41.2, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-node 0.41.2, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, poksho 0.7.0, signal-crypto 0.1.0, signal-media 0.1.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, signal-pin 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0 ``` GNU AFFERO GENERAL PUBLIC LICENSE @@ -6069,7 +6069,7 @@ express Statement of Purpose. * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ ``` -## libloading 0.6.7, libloading 0.8.1 +## libloading 0.8.1 ``` Copyright © 2015, Simonas Kazlauskas @@ -6479,7 +6479,7 @@ DEALINGS IN THE SOFTWARE. ``` -## bitflags 1.3.2, bitflags 2.4.2, glob 0.3.1, log 0.4.20, num-derive 0.4.2, num-integer 0.1.45, num-traits 0.2.17, range-map 0.2.0, regex 1.10.3, regex-automata 0.4.4, regex-syntax 0.8.2, semver 0.9.0 +## bitflags 1.3.2, bitflags 2.4.2, glob 0.3.1, log 0.4.20, num-derive 0.4.2, num-integer 0.1.45, num-traits 0.2.17, range-map 0.2.0, regex 1.10.3, regex-automata 0.4.4, regex-syntax 0.8.2 ``` Copyright (c) 2014 The Rust Project Developers @@ -6681,7 +6681,7 @@ THE SOFTWARE. ``` -## neon 0.10.1, neon-build 0.10.1, neon-macros 0.10.1, neon-runtime 0.10.1 +## neon-macros 1.0.0 ``` Copyright (c) 2015 David Herman @@ -7301,37 +7301,6 @@ DEALINGS IN THE SOFTWARE. ``` -## semver-parser 0.7.0 - -``` -Copyright (c) 2016 Steve Klabnik - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - -``` - ## lock_api 0.4.11, parking_lot 0.12.1, parking_lot_core 0.9.9, rustc_version 0.4.0 ``` @@ -9459,6 +9428,33 @@ SOFTWARE. ``` +## strum 0.26.1, strum_macros 0.26.1 + +``` +MIT License + +Copyright (c) 2019 Peter Glotfelty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + ## fslock 0.2.1 ``` @@ -9674,7 +9670,7 @@ SOFTWARE. ``` -## cesu8 1.1.0, curve25519-dalek-derive 0.1.0, half 1.8.2, pqcrypto-internals 0.2.5, pqcrypto-kyber 0.7.9, pqcrypto-kyber 0.8.0, pqcrypto-traits 0.3.5 +## cesu8 1.1.0, curve25519-dalek-derive 0.1.0, half 1.8.2, neon 1.0.0, pqcrypto-internals 0.2.5, pqcrypto-kyber 0.7.9, pqcrypto-kyber 0.8.0, pqcrypto-traits 0.3.5 ``` MIT License @@ -9788,7 +9784,7 @@ DEALINGS IN THE SOFTWARE. ``` -## adler 1.0.2, anyhow 1.0.79, async-trait 0.1.77, dyn-clone 1.0.16, fastrand 2.0.1, home 0.5.9, itoa 1.0.10, linkme 0.3.22, linkme-impl 0.3.22, linux-raw-sys 0.4.13, minimal-lexical 0.2.1, num_enum 0.6.1, num_enum_derive 0.6.1, once_cell 1.19.0, paste 1.0.14, pin-project-lite 0.2.13, prettyplease 0.2.16, proc-macro-crate 1.3.1, proc-macro2 1.0.78, quote 1.0.35, rustc-hash 1.1.0, rustix 0.38.30, semver 1.0.21, serde 1.0.195, serde_derive 1.0.195, serde_json 1.0.111, syn 1.0.109, syn 2.0.48, syn-mid 0.5.4, syn-mid 0.6.0, thiserror 1.0.56, thiserror-impl 1.0.56, unicode-ident 1.0.12, utf-8 0.7.6 +## adler 1.0.2, anyhow 1.0.79, async-trait 0.1.77, dyn-clone 1.0.16, fastrand 2.0.1, home 0.5.9, itoa 1.0.10, linkme 0.3.22, linkme-impl 0.3.22, linux-raw-sys 0.4.13, minimal-lexical 0.2.1, num_enum 0.6.1, num_enum_derive 0.6.1, once_cell 1.19.0, paste 1.0.14, pin-project-lite 0.2.13, prettyplease 0.2.16, proc-macro-crate 1.3.1, proc-macro2 1.0.78, quote 1.0.35, rustc-hash 1.1.0, rustix 0.38.30, rustversion 1.0.14, semver 1.0.21, send_wrapper 0.6.0, serde 1.0.195, serde_derive 1.0.195, serde_json 1.0.111, syn 1.0.109, syn 2.0.48, syn-mid 0.6.0, thiserror 1.0.56, thiserror-impl 1.0.56, unicode-ident 1.0.12, utf-8 0.7.6 ``` Permission is hereby granted, free of charge, to any @@ -10590,6 +10586,10 @@ written authorization of the copyright holder. ``` +## Kyber Patent License + + + # Acknowledgements for @signalapp/ringrtc RingRTC makes use of the following open source projects. diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a5db7b06ee..45e59313a9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7368,6 +7368,10 @@ "messageformat": "{count, plural, one {# other is} other {# others are}} typing.", "description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden." }, + "icu:TransportError": { + "messageformat": "Experimental WebSocket Transport is seeing too many errors. Please submit a debug log", + "description": "A toast that is shown to alpha/beta version users when an experimental transport is seeing too many errors" + }, "icu:WhoCanFindMeReadOnlyToast": { "messageformat": "To change this setting, set “Who can see my number” to “Nobody”.", "description": "A toast displayed when user clicks disabled option in settings window" diff --git a/package.json b/package.json index ef2a684999..cc457454da 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@react-aria/utils": "3.16.0", "@react-spring/web": "9.5.5", "@signalapp/better-sqlite3": "8.7.1", - "@signalapp/libsignal-client": "0.40.1", + "@signalapp/libsignal-client": "0.41.2", "@signalapp/ringrtc": "2.39.1", "@signalapp/windows-dummy-keystroke": "1.0.0", "@types/fabric": "4.5.3", diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index a4c2312d8f..a675c5669c 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -24,6 +24,8 @@ export type ConfigKeyType = | 'desktop.retryRespondMaxAge' | 'desktop.senderKey.retry' | 'desktop.senderKeyMaxAge' + | 'desktop.experimentalTransportEnabled.alpha' + | 'desktop.experimentalTransportEnabled.beta' | 'global.attachments.maxBytes' | 'global.attachments.maxReceiveBytes' | 'global.calling.maxGroupCallRingSize' diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 99e6d84536..817aeb32f0 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -141,6 +141,8 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.TapToViewExpiredIncoming }; case ToastType.TapToViewExpiredOutgoing: return { toastType: ToastType.TapToViewExpiredOutgoing }; + case ToastType.TransportError: + return { toastType: ToastType.TransportError }; case ToastType.TooManyMessagesToDeleteForEveryone: return { toastType: ToastType.TooManyMessagesToDeleteForEveryone, diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index e367ccc4d6..623af7f704 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -470,6 +470,10 @@ export function renderToast({ ); } + if (toastType === ToastType.TransportError) { + return {i18n('icu:TransportError')}; + } + if (toastType === ToastType.UnableToLoadAttachment) { return ( {i18n('icu:unableToLoadAttachment')} diff --git a/ts/test-electron/WebsocketResources_test.ts b/ts/test-electron/WebsocketResources_test.ts index cb900e205a..9a5bb47b3e 100644 --- a/ts/test-electron/WebsocketResources_test.ts +++ b/ts/test-electron/WebsocketResources_test.ts @@ -127,9 +127,9 @@ describe('WebSocket-Resource', () => { }).finish(), }); - const { status, message } = await promise; - assert.strictEqual(message, 'OK'); - assert.strictEqual(status, 200); + const response = await promise; + assert.strictEqual(response.statusText, 'OK'); + assert.strictEqual(response.status, 200); }); }); diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index 2d99cda831..20bb2e1ee3 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -2,13 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import URL from 'url'; -import type { RequestInit } from 'node-fetch'; -import { Response, Headers } from 'node-fetch'; +import type { RequestInit, Response } from 'node-fetch'; +import { Headers } from 'node-fetch'; import type { connection as WebSocket } from 'websocket'; import qs from 'querystring'; import EventListener from 'events'; -import type { AbortableProcess } from '../util/AbortableProcess'; +import { AbortableProcess } from '../util/AbortableProcess'; import { strictAssert } from '../util/assert'; import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff'; import * as durations from '../util/durations'; @@ -20,18 +20,28 @@ import * as Bytes from '../Bytes'; import * as log from '../logging/log'; import type { - WebSocketResourceOptions, IncomingWebSocketRequest, + IWebSocketResource, + WebSocketResourceOptions, +} from './WebsocketResources'; +import WebSocketResource, { + LibsignalWebSocketResource, + TransportOption, + WebSocketResourceWithShadowing, } from './WebsocketResources'; -import WebSocketResource from './WebsocketResources'; import { HTTPError } from './Errors'; -import type { WebAPICredentials, IRequestHandler } from './Types.d'; +import type { IRequestHandler, WebAPICredentials } from './Types.d'; import { connect as connectWebSocket } from './WebSocket'; +import { isAlpha, isBeta, isStaging } from '../util/version'; const FIVE_MINUTES = 5 * durations.MINUTE; const JITTER = 5 * durations.SECOND; +export const UNAUTHENTICATED_CHANNEL_NAME = 'unauthenticated'; + +export const AUTHENTICATED_CHANNEL_NAME = 'authenticated'; + export type SocketManagerOptions = Readonly<{ url: string; artCreatorUrl: string; @@ -43,9 +53,9 @@ export type SocketManagerOptions = Readonly<{ // This class manages two websocket resources: // -// - Authenticated WebSocketResource which uses supplied WebAPICredentials and +// - Authenticated IWebSocketResource which uses supplied WebAPICredentials and // automatically reconnects on closed socket (using back off) -// - Unauthenticated WebSocketResource that is created on the first outgoing +// - Unauthenticated IWebSocketResource that is created on the first outgoing // unauthenticated request and is periodically rotated (5 minutes since first // activity on the socket). // @@ -54,15 +64,15 @@ export type SocketManagerOptions = Readonly<{ // least one such request handler becomes available. // // Incoming requests on unauthenticated resource are not currently supported. -// WebSocketResource is responsible for their immediate termination. +// IWebSocketResource is responsible for their immediate termination. export class SocketManager extends EventListener { private backOff = new BackOff(FIBONACCI_TIMEOUTS, { jitter: JITTER, }); - private authenticated?: AbortableProcess; + private authenticated?: AbortableProcess; - private unauthenticated?: AbortableProcess; + private unauthenticated?: AbortableProcess; private unauthenticatedExpirationTimer?: NodeJS.Timeout; @@ -138,11 +148,11 @@ export class SocketManager extends EventListener { this.setStatus(SocketStatus.CONNECTING); const process = this.connectResource({ - name: 'authenticated', + name: AUTHENTICATED_CHANNEL_NAME, path: '/v1/websocket/', query: { login: username, password }, resourceOptions: { - name: 'authenticated', + name: AUTHENTICATED_CHANNEL_NAME, keepalive: { path: '/v1/keepalive' }, handleRequest: (req: IncomingWebSocketRequest): void => { this.queueOrHandleRequest(req); @@ -190,7 +200,7 @@ export class SocketManager extends EventListener { } }; - let authenticated: WebSocketResource; + let authenticated: IWebSocketResource; try { authenticated = await process.getResult(); this.setStatus(SocketStatus.OPEN); @@ -230,7 +240,7 @@ export class SocketManager extends EventListener { } log.info( - `SocketManager: connected authenticated socket (localPort: ${authenticated.localPort})` + `SocketManager: connected authenticated socket (localPort: ${authenticated.localPort()})` ); window.logAuthenticatedConnect?.(); @@ -262,8 +272,8 @@ export class SocketManager extends EventListener { } // Either returns currently connecting/active authenticated - // WebSocketResource or connects a fresh one. - public async getAuthenticatedResource(): Promise { + // IWebSocketResource or connects a fresh one. + public async getAuthenticatedResource(): Promise { if (!this.authenticated) { strictAssert(this.credentials !== undefined, 'Missing credentials'); await this.authenticate(this.credentials); @@ -273,10 +283,10 @@ export class SocketManager extends EventListener { return this.authenticated.getResult(); } - // Creates new WebSocketResource for AccountManager's provisioning + // Creates new IWebSocketResource for AccountManager's provisioning public async getProvisioningResource( handler: IRequestHandler - ): Promise { + ): Promise { return this.connectResource({ name: 'provisioning', path: '/v1/websocket/provisioning/', @@ -317,7 +327,7 @@ export class SocketManager extends EventListener { public async fetch(url: string, init: RequestInit): Promise { const headers = new Headers(init.headers); - let resource: WebSocketResource; + let resource: IWebSocketResource; if (this.isAuthenticated(headers)) { resource = await this.getAuthenticatedResource(); } else { @@ -343,34 +353,13 @@ export class SocketManager extends EventListener { throw new Error(`Unsupported body type: ${typeof body}`); } - const { - status, - message: statusText, - response, - headers: flatResponseHeaders, - } = await resource.sendRequest({ + return resource.sendRequest({ verb: method, path, body: bodyBytes, - headers: Array.from(headers.entries()).map(([key, value]) => { - return `${key}:${value}`; - }), + headers: Array.from(headers.entries()), 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 { @@ -427,7 +416,7 @@ export class SocketManager extends EventListener { } // Puts SocketManager into "online" state and reconnects the authenticated - // WebSocketResource (if there are valid credentials) + // IWebSocketResource (if there are valid credentials) public async onOnline(): Promise { log.info('SocketManager.onOnline'); this.isOffline = false; @@ -477,7 +466,62 @@ export class SocketManager extends EventListener { this.emit('statusChange'); } - private async getUnauthenticatedResource(): Promise { + private transportOption(): TransportOption { + const { hostname } = URL.parse(this.options.url); + + // transport experiment doesn't support proxy + if ( + this.proxyAgent || + hostname == null || + !hostname.endsWith('signal.org') + ) { + return TransportOption.Original; + } + + // in staging, switch to using libsignal transport + if (isStaging(this.options.version)) { + return TransportOption.Libsignal; + } + + // in alpha, switch to using libsignal transport, unless user opts out, + // in which case switching to shadowing + if (isAlpha(this.options.version)) { + const configValue = window.Signal.RemoteConfig.isEnabled( + 'desktop.experimentalTransportEnabled.alpha' + ); + return configValue + ? TransportOption.Libsignal + : TransportOption.ShadowingHigh; + } + + // in beta, switch to using 'ShadowingHigh' mode, unless user opts out, + // in which case switching to `ShadowingLow` + if (isBeta(this.options.version)) { + const configValue = window.Signal.RemoteConfig.isEnabled( + 'desktop.experimentalTransportEnabled.beta' + ); + return configValue + ? TransportOption.ShadowingHigh + : TransportOption.ShadowingLow; + } + + // in prod, using original + return TransportOption.ShadowingLow; + } + + private connectLibsignalUnauthenticated(): AbortableProcess { + return new AbortableProcess( + `WebSocket.connect(libsignal.${UNAUTHENTICATED_CHANNEL_NAME})`, + { + abort() { + // noop + }, + }, + Promise.resolve(new LibsignalWebSocketResource(this.options.version)) + ); + } + + private async getUnauthenticatedResource(): Promise { if (this.isOffline) { throw new HTTPError('SocketManager offline', { code: 0, @@ -490,19 +534,28 @@ export class SocketManager extends EventListener { return this.unauthenticated.getResult(); } - log.info('SocketManager: connecting unauthenticated socket'); + const transportOption = this.transportOption(); + log.info( + `SocketManager: connecting unauthenticated socket, transport option [${transportOption}]` + ); + + if (transportOption === TransportOption.Libsignal) { + this.unauthenticated = this.connectLibsignalUnauthenticated(); + return this.unauthenticated.getResult(); + } const process = this.connectResource({ - name: 'unauthenticated', + name: UNAUTHENTICATED_CHANNEL_NAME, path: '/v1/websocket/', resourceOptions: { - name: 'unauthenticated', + name: UNAUTHENTICATED_CHANNEL_NAME, keepalive: { path: '/v1/keepalive' }, + transportOption, }, }); this.unauthenticated = process; - let unauthenticated: WebSocketResource; + let unauthenticated: IWebSocketResource; try { unauthenticated = await this.unauthenticated.getResult(); } catch (error) { @@ -515,7 +568,7 @@ export class SocketManager extends EventListener { } log.info( - `SocketManager: connected unauthenticated socket (localPort: ${unauthenticated.localPort})` + `SocketManager: connected unauthenticated socket (localPort: ${unauthenticated.localPort()})` ); unauthenticated.addEventListener('close', ({ code, reason }): void => { @@ -546,7 +599,7 @@ export class SocketManager extends EventListener { resourceOptions: WebSocketResourceOptions; query?: Record; extraHeaders?: Record; - }): AbortableProcess { + }): AbortableProcess { const queryWithDefaults = { agent: 'OWD', version: this.options.version, @@ -554,24 +607,32 @@ export class SocketManager extends EventListener { }; const url = `${this.options.url}${path}?${qs.encode(queryWithDefaults)}`; + const { version } = this.options; return connectWebSocket({ name, url, + version, certificateAuthority: this.options.certificateAuthority, - version: this.options.version, proxyAgent: this.proxyAgent, extraHeaders, - createResource(socket: WebSocket): WebSocketResource { - return new WebSocketResource(socket, resourceOptions); + createResource(socket: WebSocket): IWebSocketResource { + return !resourceOptions.transportOption || + resourceOptions.transportOption === TransportOption.Original + ? new WebSocketResource(socket, resourceOptions) + : new WebSocketResourceWithShadowing( + socket, + resourceOptions, + version + ); }, }); } private static async checkResource( - process?: AbortableProcess + process?: AbortableProcess ): Promise { if (!process) { return; @@ -582,7 +643,7 @@ export class SocketManager extends EventListener { } private dropAuthenticated( - process: AbortableProcess + process: AbortableProcess ): void { if (this.authenticated !== process) { return; @@ -594,7 +655,7 @@ export class SocketManager extends EventListener { } private dropUnauthenticated( - process: AbortableProcess + process: AbortableProcess ): void { if (this.unauthenticated !== process) { return; @@ -609,7 +670,7 @@ export class SocketManager extends EventListener { } private async startUnauthenticatedExpirationTimer( - expected: WebSocketResource + expected: IWebSocketResource ): Promise { const process = this.unauthenticated; strictAssert( diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 09729a1ff5..a6a6d88ce3 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -55,7 +55,6 @@ import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid'; import { SocketManager } from './SocketManager'; import type { CDSAuthType, CDSResponseType } from './cds/Types.d'; import { CDSI } from './cds/CDSI'; -import type WebSocketResource from './WebsocketResources'; import { SignalService as Proto } from '../protobuf'; import { HTTPError } from './Errors'; @@ -70,6 +69,7 @@ import { handleStatusCode, translateError } from './Utils'; import * as log from '../logging/log'; import { maybeParseUrl, urlPathFromComponents } from '../util/url'; import { SECOND } from '../util/durations'; +import type { IWebSocketResource } from './WebsocketResources'; // Note: this will break some code that expects to be able to use err.response when a // web request fails, because it will force it to text. But it is very useful for @@ -1034,7 +1034,7 @@ export type WebAPIType = { ) => Promise; getProvisioningResource: ( handler: IRequestHandler - ) => Promise; + ) => Promise; getArtProvisioningSocket: (token: string) => Promise; getSenderCertificate: ( withUuid?: boolean @@ -3473,7 +3473,7 @@ export function initialize({ function getProvisioningResource( handler: IRequestHandler - ): Promise { + ): Promise { return socketManager.getProvisioningResource(handler); } diff --git a/ts/textsecure/WebsocketResources.ts b/ts/textsecure/WebsocketResources.ts index 80ae18f070..319787cf63 100644 --- a/ts/textsecure/WebsocketResources.ts +++ b/ts/textsecure/WebsocketResources.ts @@ -23,9 +23,19 @@ * */ +/* eslint-disable @typescript-eslint/no-namespace */ +/* eslint-disable @typescript-eslint/brace-style */ + +import { Net } from '@signalapp/libsignal-client'; import type { connection as WebSocket, IMessage } from 'websocket'; import Long from 'long'; import pTimeout from 'p-timeout'; +import { Response } from 'node-fetch'; +import net from 'net'; +import { z } from 'zod'; +import { clearInterval } from 'timers'; +import { random } from 'lodash'; +import type { DebugInfo } from '@signalapp/libsignal-client/Native'; import type { EventHandler } from './EventTarget'; import EventTarget from './EventTarget'; @@ -38,11 +48,121 @@ import * as Errors from '../types/errors'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import * as Timers from '../Timers'; +import type { IResource } from './WebSocket'; +import { isProduction, isStaging } from '../util/version'; + +import { ToastType } from '../types/Toast'; +import { drop } from '../util/drop'; const THIRTY_SECONDS = 30 * durations.SECOND; +const HEALTHCHECK_TIMEOUT = durations.SECOND; + +const STATS_UPDATE_INTERVAL = durations.MINUTE; + const MAX_MESSAGE_SIZE = 512 * 1024; +const AGGREGATED_STATS_KEY = 'websocketStats'; + +export enum IpVersion { + IPv4 = 'ipv4', + IPv6 = 'ipv6', +} + +export namespace IpVersion { + export function fromDebugInfoCode(ipType: number): IpVersion | undefined { + switch (ipType) { + case 1: + return IpVersion.IPv4; + case 2: + return IpVersion.IPv6; + default: + return undefined; + } + } +} + +const AggregatedStatsSchema = z.object({ + requestsCompared: z.number(), + ipVersionMismatches: z.number(), + unexpectedReconnects: z.number(), + healthcheckFailures: z.number(), + healthcheckBadStatus: z.number(), + lastToastTimestamp: z.number(), +}); + +export type AggregatedStats = z.infer; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export namespace AggregatedStats { + export function loadOrCreateEmpty(name: string): AggregatedStats { + const key = localStorageKey(name); + try { + const json = localStorage.getItem(key); + return json != null + ? AggregatedStatsSchema.parse(JSON.parse(json)) + : createEmpty(); + } catch (error) { + log.warn( + `Could not load [${key}] from local storage. Possibly, attempting to load for the first time`, + Errors.toLogFormat(error) + ); + return createEmpty(); + } + } + + export function store(stats: AggregatedStats, name: string): void { + const key = localStorageKey(name); + try { + const json = JSON.stringify(stats); + localStorage.setItem(key, json); + } catch (error) { + log.warn( + `Failed to store key [${key}] to the local storage`, + Errors.toLogFormat(error) + ); + } + } + + export function add(a: AggregatedStats, b: AggregatedStats): AggregatedStats { + return { + requestsCompared: a.requestsCompared + b.requestsCompared, + healthcheckFailures: a.healthcheckFailures + b.healthcheckFailures, + ipVersionMismatches: a.ipVersionMismatches + b.ipVersionMismatches, + unexpectedReconnects: a.unexpectedReconnects + b.unexpectedReconnects, + healthcheckBadStatus: a.healthcheckBadStatus + b.healthcheckBadStatus, + lastToastTimestamp: Math.max(a.lastToastTimestamp, b.lastToastTimestamp), + }; + } + + export function createEmpty(): AggregatedStats { + return { + requestsCompared: 0, + ipVersionMismatches: 0, + unexpectedReconnects: 0, + healthcheckFailures: 0, + healthcheckBadStatus: 0, + lastToastTimestamp: 0, + }; + } + + export function shouldReportError(stats: AggregatedStats): boolean { + const timeSinceLastToast = Date.now() - stats.lastToastTimestamp; + if (timeSinceLastToast < durations.DAY || stats.requestsCompared < 1000) { + return false; + } + return ( + stats.healthcheckBadStatus + stats.healthcheckFailures > 20 || + stats.ipVersionMismatches > 50 || + stats.unexpectedReconnects > 50 + ); + } + + export function localStorageKey(name: string): string { + return `${AGGREGATED_STATS_KEY}.${name}`; + } +} + export class IncomingWebSocketRequest { private readonly id: Long; @@ -84,7 +204,7 @@ export type SendRequestOptions = Readonly<{ path: string; body?: Uint8Array; timeout?: number; - headers?: ReadonlyArray; + headers?: ReadonlyArray<[string, string]>; }>; export type SendRequestResult = Readonly<{ @@ -94,10 +214,29 @@ export type SendRequestResult = Readonly<{ headers: ReadonlyArray; }>; +export enum TransportOption { + // Only original transport is used + Original = 'original', + // All requests are going through the original transport, + // but for every request that completes sucessfully we're initiating + // a healthcheck request via libsignal transport, + // collecting comparison statistics, and if we see many inconsistencies, + // we're showing a toast asking user to submit a debug log + ShadowingHigh = 'shadowingHigh', + // Similar to `shadowingHigh`, however, only 10% of requests + // will trigger a healthcheck, and toast is never shown. + // Statistics data is still added to the debug logs, + // so it will be available to us with all the debug log uploads. + ShadowingLow = 'shadowingLow', + // Only libsignal transport is used + Libsignal = 'libsignal', +} + export type WebSocketResourceOptions = { name: string; handleRequest?: (request: IncomingWebSocketRequest) => void; keepalive?: KeepAliveOptionsType; + transportOption?: TransportOption; }; export class CloseEvent extends Event { @@ -106,7 +245,225 @@ export class CloseEvent extends Event { } } -export default class WebSocketResource extends EventTarget { +// eslint-disable-next-line no-restricted-syntax +export interface IWebSocketResource extends IResource { + sendRequest(options: SendRequestOptions): Promise; + + addEventListener(name: 'close', handler: (ev: CloseEvent) => void): void; + + forceKeepAlive(): void; + + shutdown(): void; + + close(): void; + + localPort(): number | undefined; +} + +export class LibsignalWebSocketResource implements IWebSocketResource { + private readonly net: Net.Net; + + constructor(version: string) { + this.net = new Net.Net( + isStaging(version) ? Net.Environment.Staging : Net.Environment.Production + ); + } + + public localPort(): number | undefined { + return undefined; + } + + public addEventListener( + _name: 'close', + _handler: (ev: CloseEvent) => void + ): void { + // noop + } + + public close(_code?: number, _reason?: string): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.net.disconnectChatService(); + } + + public shutdown(): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.net.disconnectChatService(); + } + + public forceKeepAlive(): void { + // no-op + } + + public async sendRequest(options: SendRequestOptions): Promise { + const [response] = await this.sendRequestGetDebugInfo(options); + return response; + } + + public async sendRequestGetDebugInfo( + options: SendRequestOptions + ): Promise<[Response, DebugInfo]> { + const { response, debugInfo } = await this.net.unauthenticatedFetchAndDebug( + { + verb: options.verb, + path: options.path, + headers: options.headers ? options.headers : [], + body: options.body, + timeoutMillis: options.timeout, + } + ); + return [ + new Response(response.body, { + status: response.status, + statusText: response.message, + headers: [...response.headers], + }), + debugInfo, + ]; + } +} + +export class WebSocketResourceWithShadowing implements IWebSocketResource { + private main: WebSocketResource; + + private shadowing: LibsignalWebSocketResource; + + private stats: AggregatedStats; + + private statsTimer: NodeJS.Timer; + + private shadowingWithReporting: boolean; + + private logId: string; + + constructor( + socket: WebSocket, + options: WebSocketResourceOptions, + version: string + ) { + this.main = new WebSocketResource(socket, options); + this.shadowing = new LibsignalWebSocketResource(version); + this.stats = AggregatedStats.createEmpty(); + this.logId = `WebSocketResourceWithShadowing(${options.name})`; + this.statsTimer = setInterval( + () => this.updateStats(options.name), + STATS_UPDATE_INTERVAL + ); + this.shadowingWithReporting = + options.transportOption === TransportOption.ShadowingHigh; + + this.addEventListener('close', (_ev): void => { + clearInterval(this.statsTimer); + this.updateStats(options.name); + }); + } + + private updateStats(name: string) { + const storedStats = AggregatedStats.loadOrCreateEmpty(name); + const updatedStats = AggregatedStats.add(storedStats, this.stats); + if ( + this.shadowingWithReporting && + AggregatedStats.shouldReportError(updatedStats) && + !isProduction(window.getVersion()) + ) { + window.reduxActions.toast.showToast({ + toastType: ToastType.TransportError, + }); + updatedStats.lastToastTimestamp = Date.now(); + } + AggregatedStats.store(updatedStats, name); + this.stats = AggregatedStats.createEmpty(); + } + + public localPort(): number | undefined { + return this.main.localPort(); + } + + public addEventListener( + name: 'close', + handler: (ev: CloseEvent) => void + ): void { + this.main.addEventListener(name, handler); + } + + public close(): void { + this.main.close(); + this.shadowing.close(); + } + + public shutdown(): void { + this.main.shutdown(); + this.shadowing.shutdown(); + } + + public forceKeepAlive(): void { + this.main.forceKeepAlive(); + } + + public async sendRequest(options: SendRequestOptions): Promise { + const responsePromise = this.main.sendRequest(options); + const response = await responsePromise; + + // if we're received a response from the main channel and the status was successful, + // attempting to run a healthcheck on a libsignal transport. + if ( + isSuccessfulStatusCode(response.status) && + this.shouldSendShadowRequest() + ) { + drop(this.sendShadowRequest()); + } + + return response; + } + + private async sendShadowRequest(): Promise { + try { + const [healthCheckResult, debugInfo] = + await this.shadowing.sendRequestGetDebugInfo({ + verb: 'GET', + path: '/v1/keepalive', + timeout: HEALTHCHECK_TIMEOUT, + }); + this.stats.requestsCompared += 1; + if (!isSuccessfulStatusCode(healthCheckResult.status)) { + this.stats.healthcheckBadStatus += 1; + log.warn( + `${this.logId}: keepalive via libsignal responded with status [${healthCheckResult.status}]` + ); + } + const ipVersion = IpVersion.fromDebugInfoCode(debugInfo.ipType); + if (this.main.ipVersion() !== ipVersion) { + this.stats.ipVersionMismatches += 1; + log.warn( + `${ + this.logId + }: keepalive via libsignal using IP [${ipVersion}] while main is using IP [${this.main.ipVersion()}]` + ); + } + if (debugInfo.reconnectCount > 1) { + this.stats.unexpectedReconnects = debugInfo.reconnectCount - 1; + } + } catch (error) { + this.stats.healthcheckFailures += 1; + log.warn( + `${this.logId}: failed to send keepalive via libsignal`, + Errors.toLogFormat(error) + ); + } + } + + private shouldSendShadowRequest(): boolean { + return this.shadowingWithReporting || random(0, 100) < 10; + } +} + +function isSuccessfulStatusCode(status: number): boolean { + return status >= 200 && status < 300; +} + +export default class WebSocketResource + extends EventTarget + implements IWebSocketResource +{ private outgoingId = Long.fromNumber(1, true); private closed = false; @@ -126,7 +483,9 @@ export default class WebSocketResource extends EventTarget { private readonly logId: string; - public readonly localPort: number | undefined; + private readonly localSocketPort: number | undefined; + + private readonly socketIpVersion: IpVersion | undefined; // Public for tests public readonly keepalive?: KeepAlive; @@ -138,7 +497,20 @@ export default class WebSocketResource extends EventTarget { super(); this.logId = `WebSocketResource(${options.name})`; - this.localPort = socket.socket.localPort; + this.localSocketPort = socket.socket.localPort; + + if (!socket.socket.localAddress) { + this.socketIpVersion = undefined; + } + if (socket.socket.localAddress == null) { + this.socketIpVersion = undefined; + } else if (net.isIPv4(socket.socket.localAddress)) { + this.socketIpVersion = IpVersion.IPv4; + } else if (net.isIPv6(socket.socket.localAddress)) { + this.socketIpVersion = IpVersion.IPv6; + } else { + this.socketIpVersion = undefined; + } this.boundOnMessage = this.onMessage.bind(this); @@ -169,6 +541,14 @@ export default class WebSocketResource extends EventTarget { this.addEventListener('close', () => this.onClose()); } + public ipVersion(): IpVersion | undefined { + return this.socketIpVersion; + } + + public localPort(): number | undefined { + return this.localSocketPort; + } + public override addEventListener( name: 'close', handler: (ev: CloseEvent) => void @@ -178,9 +558,7 @@ export default class WebSocketResource extends EventTarget { return super.addEventListener(name, handler); } - public async sendRequest( - options: SendRequestOptions - ): Promise { + public async sendRequest(options: SendRequestOptions): Promise { const id = this.outgoingId; const idString = id.toString(); strictAssert(!this.outgoingMap.has(idString), 'Duplicate outgoing request'); @@ -194,7 +572,13 @@ export default class WebSocketResource extends EventTarget { verb: options.verb, path: options.path, body: options.body, - headers: options.headers ? options.headers.slice() : undefined, + headers: options.headers + ? options.headers + .map(([key, value]) => { + return `${key}:${value}`; + }) + .slice() + : undefined, id, }, }).finish(); @@ -239,7 +623,8 @@ export default class WebSocketResource extends EventTarget { this.socket.sendBytes(Buffer.from(bytes)); - return promise; + const requestResult = await promise; + return WebSocketResource.intoResponse(requestResult); } public forceKeepAlive(): void { @@ -399,6 +784,27 @@ export default class WebSocketResource extends EventTarget { log.info(`${this.logId}.removeActive: shutdown complete`); this.close(3000, 'Shutdown'); } + + private static intoResponse(sendRequestResult: SendRequestResult): Response { + const { + status, + message: statusText, + response, + headers: flatResponseHeaders, + } = sendRequestResult; + + const headers: 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, + }); + } } export type KeepAliveOptionsType = { diff --git a/ts/textsecure/cds/Types.d.ts b/ts/textsecure/cds/Types.d.ts index a0fb32bbee..a5b4e8ded3 100644 --- a/ts/textsecure/cds/Types.d.ts +++ b/ts/textsecure/cds/Types.d.ts @@ -4,7 +4,7 @@ import type { Net } from '@signalapp/libsignal-client'; import type { AciString, PniString } from '../../types/ServiceId'; -export type CDSAuthType = Net.CDSAuthType; +export type CDSAuthType = Net.ServiceAuth; export type CDSResponseEntryType = Net.CDSResponseEntryType< AciString, PniString diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 334bb4a62a..687f09dcca 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -56,6 +56,7 @@ export enum ToastType { TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing', TooManyMessagesToDeleteForEveryone = 'TooManyMessagesToDeleteForEveryone', TooManyMessagesToForward = 'TooManyMessagesToForward', + TransportError = 'TransportError', UnableToLoadAttachment = 'UnableToLoadAttachment', UnsupportedMultiAttachment = 'UnsupportedMultiAttachment', UnsupportedOS = 'UnsupportedOS', @@ -136,6 +137,7 @@ export type AnyToast = parameters: { count: number }; } | { toastType: ToastType.TooManyMessagesToForward } + | { toastType: ToastType.TransportError } | { toastType: ToastType.UnableToLoadAttachment } | { toastType: ToastType.UnsupportedMultiAttachment } | { toastType: ToastType.UnsupportedOS } diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index 99d8e4cfc0..f7ec2f7588 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -22,6 +22,8 @@ import type { WindowsNotificationData, } from '../../services/notifications'; import { isAdhocCallingEnabled } from '../../util/isAdhocCallingEnabled'; +import { AggregatedStats } from '../../textsecure/WebsocketResources'; +import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager'; // It is important to call this as early as possible window.i18n = SignalContext.i18n; @@ -171,6 +173,15 @@ if (config.ciMode !== 'full' && config.environment !== 'test') { window.eval = global.eval = () => null; } +type NetworkStatistics = { + signalConnectionCount?: string; + unauthorizedRequestsCompared?: string; + unauthorizedHealthcheckFailures?: string; + unauthorizedHealthcheckBadStatus?: string; + unauthorizedUnexpectedReconnects?: string; + unauthorizedIpVersionMismatches?: string; +}; + ipc.on('additional-log-data-request', async event => { const ourConversation = window.ConversationController.getOurConversation(); const ourCapabilities = ourConversation @@ -186,6 +197,33 @@ ipc.on('additional-log-data-request', async event => { statistics = {}; } + let networkStatistics: NetworkStatistics = { + signalConnectionCount: formatCountForLogging(getSignalConnections().length), + }; + const unauthorizedStats = AggregatedStats.loadOrCreateEmpty( + UNAUTHENTICATED_CHANNEL_NAME + ); + if (unauthorizedStats.requestsCompared > 0) { + networkStatistics = { + ...networkStatistics, + unauthorizedRequestsCompared: formatCountForLogging( + unauthorizedStats.requestsCompared + ), + unauthorizedHealthcheckFailures: formatCountForLogging( + unauthorizedStats.healthcheckFailures + ), + unauthorizedHealthcheckBadStatus: formatCountForLogging( + unauthorizedStats.healthcheckBadStatus + ), + unauthorizedUnexpectedReconnects: formatCountForLogging( + unauthorizedStats.unexpectedReconnects + ), + unauthorizedIpVersionMismatches: formatCountForLogging( + unauthorizedStats.ipVersionMismatches + ), + }; + } + const ourAci = window.textsecure.storage.user.getAci(); const ourPni = window.textsecure.storage.user.getPni(); @@ -198,9 +236,7 @@ ipc.on('additional-log-data-request', async event => { }), statistics: { ...statistics, - signalConnectionCount: formatCountForLogging( - getSignalConnections().length - ), + ...networkStatistics, }, user: { deviceId: window.textsecure.storage.user.getDeviceId(), diff --git a/yarn.lock b/yarn.lock index dd1ced04aa..9290615fc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3983,10 +3983,10 @@ bindings "^1.5.0" tar "^6.1.0" -"@signalapp/libsignal-client@0.40.1": - version "0.40.1" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.40.1.tgz#07ed6d3da9d2458aa7e66f5a71a8a7ef6b4a467a" - integrity sha512-5BLDpOrP7eXT9U1Vf8F3HVOt4dnLeFMCzalkSyGdFgzD59i/wva3PHtQtbfKhxX7pNaXwhele1tAbIXsKYtXUQ== +"@signalapp/libsignal-client@0.41.2": + version "0.41.2" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.41.2.tgz#da8d415168ab1f89c0b6e05d259feab60893e717" + integrity sha512-GQh0AJ1gwifYbZhe+974e17Qj0Fymiv5qM1u0NHtr5SK0bTly7stTi14JYJ9exlOysaLMq1QdAt/CoQFwfQahg== dependencies: node-gyp-build "^4.2.3" type-fest "^3.5.0"