unauthenticated WebSocket via libsignal: shadowing mode

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Sergey Skrobotov 2024-03-14 14:08:51 -07:00 committed by GitHub
parent d696a2c082
commit 9f40562b19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 636 additions and 119 deletions

View file

@ -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
<https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf>
# Acknowledgements for @signalapp/ringrtc
RingRTC makes use of the following open source projects.

View file

@ -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"

View file

@ -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",

View file

@ -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'

View file

@ -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,

View file

@ -470,6 +470,10 @@ export function renderToast({
);
}
if (toastType === ToastType.TransportError) {
return <Toast onClose={hideToast}>{i18n('icu:TransportError')}</Toast>;
}
if (toastType === ToastType.UnableToLoadAttachment) {
return (
<Toast onClose={hideToast}>{i18n('icu:unableToLoadAttachment')}</Toast>

View file

@ -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);
});
});

View file

@ -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<WebSocketResource>;
private authenticated?: AbortableProcess<IWebSocketResource>;
private unauthenticated?: AbortableProcess<WebSocketResource>;
private unauthenticated?: AbortableProcess<IWebSocketResource>;
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<WebSocketResource> {
// IWebSocketResource or connects a fresh one.
public async getAuthenticatedResource(): Promise<IWebSocketResource> {
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<WebSocketResource> {
): Promise<IWebSocketResource> {
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<Response> {
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<void> {
log.info('SocketManager.onOnline');
this.isOffline = false;
@ -477,7 +466,62 @@ export class SocketManager extends EventListener {
this.emit('statusChange');
}
private async getUnauthenticatedResource(): Promise<WebSocketResource> {
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<IWebSocketResource> {
return new AbortableProcess<IWebSocketResource>(
`WebSocket.connect(libsignal.${UNAUTHENTICATED_CHANNEL_NAME})`,
{
abort() {
// noop
},
},
Promise.resolve(new LibsignalWebSocketResource(this.options.version))
);
}
private async getUnauthenticatedResource(): Promise<IWebSocketResource> {
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<string, string>;
extraHeaders?: Record<string, string>;
}): AbortableProcess<WebSocketResource> {
}): AbortableProcess<IWebSocketResource> {
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<WebSocketResource>
process?: AbortableProcess<IWebSocketResource>
): Promise<void> {
if (!process) {
return;
@ -582,7 +643,7 @@ export class SocketManager extends EventListener {
}
private dropAuthenticated(
process: AbortableProcess<WebSocketResource>
process: AbortableProcess<IWebSocketResource>
): void {
if (this.authenticated !== process) {
return;
@ -594,7 +655,7 @@ export class SocketManager extends EventListener {
}
private dropUnauthenticated(
process: AbortableProcess<WebSocketResource>
process: AbortableProcess<IWebSocketResource>
): void {
if (this.unauthenticated !== process) {
return;
@ -609,7 +670,7 @@ export class SocketManager extends EventListener {
}
private async startUnauthenticatedExpirationTimer(
expected: WebSocketResource
expected: IWebSocketResource
): Promise<void> {
const process = this.unauthenticated;
strictAssert(

View file

@ -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<unknown>;
getProvisioningResource: (
handler: IRequestHandler
) => Promise<WebSocketResource>;
) => Promise<IWebSocketResource>;
getArtProvisioningSocket: (token: string) => Promise<WebSocket>;
getSenderCertificate: (
withUuid?: boolean
@ -3473,7 +3473,7 @@ export function initialize({
function getProvisioningResource(
handler: IRequestHandler
): Promise<WebSocketResource> {
): Promise<IWebSocketResource> {
return socketManager.getProvisioningResource(handler);
}

View file

@ -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<typeof AggregatedStatsSchema>;
// 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<string>;
headers?: ReadonlyArray<[string, string]>;
}>;
export type SendRequestResult = Readonly<{
@ -94,10 +214,29 @@ export type SendRequestResult = Readonly<{
headers: ReadonlyArray<string>;
}>;
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<Response>;
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<Response> {
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<Response> {
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<void> {
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<SendRequestResult> {
public async sendRequest(options: SendRequestOptions): Promise<Response> {
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 = {

View file

@ -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

View file

@ -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 }

View file

@ -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(),

View file

@ -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"