Handle server alerts received on libsignal auth socket

Co-authored-by: trevor-signal <trevor@signal.org>
This commit is contained in:
Alex Bakon
2025-03-07 15:30:49 -05:00
committed by GitHub
parent 2aef75dede
commit 5b130ae780
9 changed files with 105 additions and 38 deletions

View File

@@ -512,7 +512,6 @@ export async function startApp(): Promise<void> {
restoreRemoteConfigFromStorage();
window.Whisper.events.on('firstEnvelope', checkFirstEnvelope);
window.Whisper.events.on('serverAlerts', handleServerAlerts);
server = window.WebAPI.connect({
...window.textsecure.storage.user.getWebAPICredentials(),
@@ -1930,8 +1929,12 @@ export async function startApp(): Promise<void> {
function afterEveryAuthConnect() {
log.info('afterAuthSocketConnect/afterEveryAuthConnect');
strictAssert(server, 'afterEveryAuthConnect: server');
handleServerAlerts(server.getServerAlerts());
strictAssert(challengeHandler, 'afterEveryAuthConnect: challengeHandler');
drop(challengeHandler.onOnline());
reconnectBackOff.reset();
drop(window.Signal.Services.initializeGroupCredentialFetcher());
drop(AttachmentDownloadManager.start());

View File

@@ -13,18 +13,15 @@ export type PropsType = {
i18n: LocalizerType;
};
const SUPPORT_PAGE =
'https://support.signal.org/hc/articles/8997185514138-Re-connect-your-primary-device-to-continue-using-Signal-Desktop';
export function CriticalIdlePrimaryDeviceDialog({
containerWidthBreakpoint,
i18n,
}: PropsType): JSX.Element {
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
<a
key="signal-support"
// TODO: DESKTOP-8377
href="https://support.signal.org/"
rel="noreferrer"
target="_blank"
>
<a href={SUPPORT_PAGE} rel="noreferrer" target="_blank">
{parts}
</a>
);

View File

@@ -13,6 +13,16 @@ export type ServerStateType = ReadonlyDeep<{
alerts: Array<ServerAlert>;
}>;
export function parseServerAlertFromHeader(
headerValue: string
): ServerAlert | undefined {
if (headerValue.toLowerCase() === 'critical-idle-primary-device') {
return ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE;
}
return undefined;
}
// Actions
const UPDATE_SERVER_ALERTS = 'server/UPDATE_SERVER_ALERTS';

View File

@@ -15,6 +15,7 @@ import type { Locator, Page } from 'playwright';
import { expect } from 'playwright/test';
import type { SignalService } from '../protobuf';
import { strictAssert } from '../util/assert';
import type { App, Bootstrap } from './bootstrap';
const debug = createDebug('mock:test:helpers');
@@ -434,3 +435,29 @@ export async function createCallLink(
const testId = await callLinkItem.getAttribute('data-testid');
return testId || undefined;
}
export async function setupAppToUseLibsignalWebsockets(
bootstrap: Bootstrap
): Promise<App> {
bootstrap.server.setRemoteConfig(
'desktop.experimentalTransportEnabled.alpha',
{ enabled: true }
);
bootstrap.server.setRemoteConfig('desktop.experimentalTransport.enableAuth', {
enabled: true,
});
// Link & close so that app can get remote config first over non-libsignal websocket,
// and then on next app start it will connect via libsignal
await bootstrap.linkAndClose();
return bootstrap.startApp();
}
export async function assertAppWasUsingLibsignalWebsockets(
app: App
): Promise<void> {
const { authenticated, unauthenticated } = await app.getSocketStatus();
assert.strictEqual(authenticated.lastConnectionTransport, 'libsignal');
assert.strictEqual(unauthenticated.lastConnectionTransport, 'libsignal');
}

View File

@@ -6,7 +6,12 @@ import { type PrimaryDevice, StorageState } from '@signalapp/mock-server';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import { typeIntoInput, waitForEnabledComposer } from '../helpers';
import {
assertAppWasUsingLibsignalWebsockets,
setupAppToUseLibsignalWebsockets,
typeIntoInput,
waitForEnabledComposer,
} from '../helpers';
import { MINUTE } from '../../util/durations';
export const debug = createDebug('mock:test:libsignal');
@@ -32,23 +37,13 @@ describe('Libsignal-net', function (this: Mocha.Suite) {
await bootstrap.phone.setStorageState(state);
bootstrap.server.setRemoteConfig(
'desktop.experimentalTransportEnabled.alpha',
{ enabled: true }
);
bootstrap.server.setRemoteConfig(
'desktop.experimentalTransport.enableAuth',
{ enabled: true }
);
// Link & close so that app can get remote config first over non-libsignal websocket,
// and then on next app start it will connect via libsignal
await bootstrap.linkAndClose();
app = await bootstrap.startApp();
app = await setupAppToUseLibsignalWebsockets(bootstrap);
});
afterEach(async function (this: Mocha.Context) {
debug('confirming that app was actually using libsignal');
await assertAppWasUsingLibsignalWebsockets(app);
if (!bootstrap) {
return;
}
@@ -86,10 +81,5 @@ describe('Libsignal-net', function (this: Mocha.Suite) {
debug('confirming app successfully sent message');
await app.waitForMessageSend();
debug('confirming that app was actually using libsignal');
const { authenticated, unauthenticated } = await app.getSocketStatus();
assert.strictEqual(authenticated.lastConnectionTransport, 'libsignal');
assert.strictEqual(unauthenticated.lastConnectionTransport, 'libsignal');
});
});

View File

@@ -4,7 +4,11 @@ import createDebug from 'debug';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import { getLeftPane } from '../helpers';
import {
assertAppWasUsingLibsignalWebsockets,
getLeftPane,
setupAppToUseLibsignalWebsockets,
} from '../helpers';
import { MINUTE } from '../../util/durations';
export const debug = createDebug('mock:test:serverAlerts');
@@ -29,7 +33,7 @@ describe('serverAlerts', function (this: Mocha.Suite) {
await bootstrap.teardown();
});
it('shows idle primary device alert', async () => {
it('shows critical idle primary device alert using classic desktop socket', async () => {
bootstrap.server.setWebsocketUpgradeResponseHeaders({
'X-Signal-Alert': 'critical-idle-primary-device',
});
@@ -37,4 +41,18 @@ describe('serverAlerts', function (this: Mocha.Suite) {
const window = await app.getWindow();
await getLeftPane(window).getByText('Open Signal on your phone').waitFor();
});
it('shows critical idle primary device alert using libsignal socket', async () => {
bootstrap.server.setWebsocketUpgradeResponseHeaders({
'X-Signal-Alert': 'critical-idle-primary-device',
});
app = await setupAppToUseLibsignalWebsockets(bootstrap);
const window = await app.getWindow();
await getLeftPane(window).getByText('Open Signal on your phone').waitFor();
debug('confirming that app was actually using libsignal');
await assertAppWasUsingLibsignalWebsockets(app);
});
});

View File

@@ -51,7 +51,8 @@ import { isNightly, isBeta, isStaging } from '../util/version';
import { getBasicAuth } from '../util/getBasicAuth';
import { isTestOrMockEnvironment } from '../environment';
import type { ConfigKeyType } from '../RemoteConfig';
import { ServerAlert } from '../state/ducks/server';
import type { ServerAlert } from '../state/ducks/server';
import { parseServerAlertFromHeader } from '../state/ducks/server';
const FIVE_MINUTES = 5 * durations.MINUTE;
@@ -205,6 +206,9 @@ export class SocketManager extends EventListener {
handler: (req: IncomingWebSocketRequest): void => {
this.#queueOrHandleRequest(req);
},
onReceivedAlerts: (alerts: Array<ServerAlert>) => {
this.emit('serverAlerts', alerts);
},
receiveStories: !this.#hasStoriesDisabled,
keepalive: { path: '/v1/keepalive' },
})
@@ -972,12 +976,9 @@ export class SocketManager extends EventListener {
}
}
const serverAlerts: Array<ServerAlert> = [];
alerts.forEach(alert => {
if (alert.toLowerCase() === 'critical-idle-primary-device') {
serverAlerts.push(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE);
}
});
const serverAlerts: Array<ServerAlert> = alerts
.map(parseServerAlertFromHeader)
.filter(v => v !== undefined);
this.emit('serverAlerts', serverAlerts);
}

View File

@@ -82,6 +82,7 @@ import { getMockServerPort } from '../util/getMockServerPort';
import { pemToDer } from '../util/pemToDer';
import { ToastType } from '../types/Toast';
import { isProduction } from '../util/version';
import type { ServerAlert } from '../state/ducks/server';
// 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
@@ -1606,6 +1607,7 @@ export type WebAPIType = {
getConfig: () => Promise<RemoteConfigResponseType>;
authenticate: (credentials: WebAPICredentials) => Promise<void>;
logout: () => Promise<void>;
getServerAlerts: () => Array<ServerAlert>;
getSocketStatus: () => SocketStatuses;
registerRequestHandler: (handler: IRequestHandler) => void;
unregisterRequestHandler: (handler: IRequestHandler) => void;
@@ -1761,6 +1763,10 @@ export function initialize({
}
}
// We store server alerts (returned on the WS upgrade response headers) so that the app
// can query them later, which is necessary if they arrive before app state is ready
let serverAlerts: Array<ServerAlert> = [];
// Thanks to function-hoisting, we can put this return statement before all of the
// below function definitions.
return {
@@ -1813,7 +1819,8 @@ export function initialize({
});
socketManager.on('serverAlerts', alerts => {
window.Whisper.events.trigger('serverAlerts', alerts);
log.info(`onServerAlerts: number of alerts received: ${alerts.length}`);
serverAlerts = alerts;
});
if (useWebSocket) {
@@ -1939,6 +1946,7 @@ export function initialize({
getReleaseNoteImageAttachment,
getTransferArchive,
getSenderCertificate,
getServerAlerts,
getSocketStatus,
getSticker,
getStickerPackManifest,
@@ -2143,6 +2151,10 @@ export function initialize({
return socketManager.getStatus();
}
function getServerAlerts(): Array<ServerAlert> {
return serverAlerts;
}
function checkSockets(): void {
// Intentionally not awaiting
void socketManager.check();

View File

@@ -58,6 +58,8 @@ import { AbortableProcess } from '../util/AbortableProcess';
import type { WebAPICredentials } from './Types';
import { NORMAL_DISCONNECT_CODE } from './SocketManager';
import { parseUnknown } from '../util/schemas';
import type { ServerAlert } from '../state/ducks/server';
import { parseServerAlertFromHeader } from '../state/ducks/server';
const THIRTY_SECONDS = 30 * durations.SECOND;
@@ -345,11 +347,13 @@ export function connectAuthenticatedLibsignal({
handler,
receiveStories,
keepalive,
onReceivedAlerts,
}: {
libsignalNet: Net.Net;
name: string;
credentials: WebAPICredentials;
handler: (request: IncomingWebSocketRequest) => void;
onReceivedAlerts: (alerts: Array<ServerAlert>) => void;
receiveStories: boolean;
keepalive: KeepAliveOptionsType;
}): AbortableProcess<LibsignalWebSocketResource> {
@@ -391,6 +395,11 @@ export function connectAuthenticatedLibsignal({
this.resource.onConnectionInterrupted(cause);
this.resource = undefined;
},
onReceivedAlerts(alerts: Array<string>): void {
onReceivedAlerts(
alerts.map(parseServerAlertFromHeader).filter(v => v !== undefined)
);
},
};
return connectLibsignal(
(abortSignal: AbortSignal) =>