QR code rotation

This commit is contained in:
Fedor Indutny 2025-01-14 12:14:32 -08:00 committed by GitHub
parent f4e5b8c80e
commit ba80d310d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 579 additions and 404 deletions

View file

@ -1582,6 +1582,10 @@
"messageformat": "Retry",
"description": "Text of the button shown on the install screen if the QR code fails to load"
},
"icu:Install__qr-max-rotations__retry": {
"messageformat": "Refresh code",
"description": "Text of the button shown on the install screen if the QR code rotated too many times and needs to be manually refreshed"
},
"icu:Install__qr-failed-load__get-help": {
"messageformat": "Get help",
"description": "Text of the link to support page shown on the install screen if the QR code fails to load"

View file

@ -58,6 +58,28 @@
color: variables.$color-gray-60;
}
&--just-button {
background: variables.$color-gray-05;
}
&--just-button &__link {
background: variables.$color-white;
color: variables.$color-black;
padding-block: 8px;
padding-inline: 16px;
border-radius: 34px;
@include mixins.font-body-1;
font-weight: 500;
margin-block-start: 0;
&::before {
@include mixins.color-svg(
'../images/icons/v3/refresh/refresh-bold.svg',
variables.$color-black
);
}
}
&__link {
@include mixins.button-reset;
& {

View file

@ -128,6 +128,17 @@ export function SimulatedLoading(): JSX.Element {
return <Simulation finalResult={LOADED_URL} />;
}
export function SimulatedMaxRotationsError(): JSX.Element {
return (
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: InstallScreenQRCodeError.MaxRotations,
}}
/>
);
}
export function SimulatedUnknownError(): JSX.Element {
return (
<Simulation

View file

@ -132,6 +132,8 @@ function InstallScreenQrCode(
const { i18n } = props;
let contents: ReactNode;
let isJustButton = false;
switch (props.loadingState) {
case LoadingState.Loading:
contents = <Spinner size="24px" svgSize="small" />;
@ -146,7 +148,9 @@ function InstallScreenQrCode(
>
{i18n('icu:Install__qr-failed-load__error--timeout')}
</span>
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
<RetryButton onClick={props.retryGetQrCode}>
{i18n('icu:Install__qr-failed-load__retry')}
</RetryButton>
</>
);
break;
@ -162,7 +166,9 @@ function InstallScreenQrCode(
components={{ paragraph: Paragraph }}
/>
</span>
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
<RetryButton onClick={props.retryGetQrCode}>
{i18n('icu:Install__qr-failed-load__retry')}
</RetryButton>
</>
);
break;
@ -186,6 +192,14 @@ function InstallScreenQrCode(
</>
);
break;
case InstallScreenQRCodeError.MaxRotations:
isJustButton = true;
contents = (
<RetryButton onClick={props.retryGetQrCode}>
{i18n('icu:Install__qr-max-rotations__retry')}
</RetryButton>
);
break;
default:
throw missingCaseError(props.error);
}
@ -210,7 +224,8 @@ function InstallScreenQrCode(
props.loadingState === LoadingState.Loaded &&
getQrCodeClassName('--loaded'),
props.loadingState === LoadingState.LoadFailed &&
getQrCodeClassName('--load-failed')
getQrCodeClassName('--load-failed'),
isJustButton && getQrCodeClassName('--just-button')
)}
>
{contents}
@ -219,11 +234,11 @@ function InstallScreenQrCode(
}
function RetryButton({
i18n,
onClick,
children,
}: {
i18n: LocalizerType;
onClick: () => void;
children: ReactNode;
}): JSX.Element {
const onKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLButtonElement>) => {
@ -243,7 +258,7 @@ function RetryButton({
onKeyDown={onKeyDown}
type="button"
>
{i18n('icu:Install__qr-failed-load__retry')}
{children}
</button>
);
}

View file

@ -3,7 +3,6 @@
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import pTimeout, { TimeoutError } from 'p-timeout';
import type { StateType as RootStateType } from '../reducer';
import {
@ -17,13 +16,14 @@ import * as Errors from '../../types/errors';
import { type Loadable, LoadingState } from '../../util/loadable';
import { isRecord } from '../../util/isRecord';
import { strictAssert } from '../../util/assert';
import { SECOND } from '../../util/durations';
import * as Registration from '../../util/registration';
import { isBackupEnabled } from '../../util/isBackupEnabled';
import { HTTPError, InactiveTimeoutError } from '../../textsecure/Errors';
import { missingCaseError } from '../../util/missingCaseError';
import { HTTPError } from '../../textsecure/Errors';
import {
Provisioner,
type PrepareLinkDataOptionsType,
EventKind as ProvisionEventKind,
type EnvelopeType as ProvisionEnvelopeType,
} from '../../textsecure/Provisioner';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
@ -31,14 +31,10 @@ import * as log from '../../logging/log';
import { backupsService } from '../../services/backups';
import OS from '../../util/os/osMain';
const SLEEP_ERROR = new TimeoutError();
const QR_CODE_TIMEOUTS = [10 * SECOND, 20 * SECOND, 30 * SECOND, 60 * SECOND];
export type BatonType = ReadonlyDeep<{ __installer_baton: never }>;
const controllerByBaton = new WeakMap<BatonType, AbortController>();
const provisionerByBaton = new WeakMap<BatonType, Provisioner>();
const cancelByBaton = new WeakMap<BatonType, () => void>();
let provisioner: Provisioner | undefined;
export type InstallerStateType = ReadonlyDeep<
| {
@ -48,12 +44,12 @@ export type InstallerStateType = ReadonlyDeep<
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
baton: BatonType;
attemptCount: number;
}
| {
step: InstallScreenStep.ChoosingDeviceName;
deviceName: string;
backupFile?: File;
envelope: ProvisionEnvelopeType;
baton: BatonType;
}
| {
@ -118,6 +114,7 @@ type QRCodeScannedActionType = ReadonlyDeep<{
payload: {
deviceName: string;
baton: BatonType;
envelope: ProvisionEnvelopeType;
};
}>;
@ -193,82 +190,49 @@ function startInstaller(): ThunkAction<
state.step === InstallScreenStep.QrCodeNotScanned,
'Unexpected step after START_INSTALLER'
);
const { attemptCount } = state;
// Can't retry past attempt count
if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) {
const { server } = window.textsecure;
strictAssert(server, 'Expected a server');
if (!provisioner) {
provisioner = new Provisioner({
server,
appVersion: window.getVersion(),
});
}
const cancel = provisioner.subscribe(event => {
if (event.kind === ProvisionEventKind.MaxRotationsError) {
log.warn('InstallScreen/getQRCode: max rotations reached');
dispatch({
type: SET_QR_CODE_ERROR,
payload: InstallScreenQRCodeError.MaxRotations,
});
} else if (event.kind === ProvisionEventKind.TimeoutError) {
if (event.canRetry) {
log.warn('InstallScreen/getQRCode: timed out');
dispatch({
type: SET_QR_CODE_ERROR,
payload: InstallScreenQRCodeError.Timeout,
});
} else {
log.error('InstallScreen/getQRCode: too many tries');
dispatch({
type: SET_ERROR,
payload: InstallScreenError.QRCodeFailed,
});
return;
}
const { server } = window.textsecure;
strictAssert(server, 'Expected a server');
const provisioner = new Provisioner({
server,
appVersion: window.getVersion(),
});
const abortController = new AbortController();
const { signal } = abortController;
signal.addEventListener('abort', () => {
provisioner.close();
});
controllerByBaton.set(baton, abortController);
// Wait to get QR code
try {
const qrCodePromise = provisioner.getURL();
const sleepMs = QR_CODE_TIMEOUTS[attemptCount];
log.info(`installer/getQRCode: race to ${sleepMs}ms`);
const url = await pTimeout(qrCodePromise, sleepMs, SLEEP_ERROR);
if (signal.aborted) {
return;
}
dispatch({
type: SET_PROVISIONING_URL,
payload: url,
});
} catch (error) {
provisioner.close();
if (signal.aborted) {
return;
}
} else if (event.kind === ProvisionEventKind.ConnectError) {
const { error } = event;
log.error(
'installer: got an error while waiting for QR code',
Errors.toLogFormat(error)
);
// Too many attempts, there is probably some issue
if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) {
log.error('InstallScreen/getQRCode: too many tries');
dispatch({
type: SET_ERROR,
payload: InstallScreenError.QRCodeFailed,
});
return;
}
// Timed out, let user retry
if (error === SLEEP_ERROR) {
dispatch({
type: SET_QR_CODE_ERROR,
payload: InstallScreenQRCodeError.Timeout,
});
return;
}
if (error instanceof HTTPError && error.code === -1) {
if (
error instanceof HTTPError &&
error.code === -1 &&
isRecord(error.cause) &&
error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
) {
@ -278,69 +242,51 @@ function startInstaller(): ThunkAction<
});
return;
}
dispatch({
type: SET_ERROR,
payload: InstallScreenError.ConnectionFailed,
});
return;
}
} else if (event.kind === ProvisionEventKind.EnvelopeError) {
log.error(
'installer: got an error while waiting for envelope',
Errors.toLogFormat(event.error)
);
dispatch({
type: SET_QR_CODE_ERROR,
payload: InstallScreenQRCodeError.Unknown,
});
return;
}
} else if (event.kind === ProvisionEventKind.URL) {
window.SignalCI?.setProvisioningURL(event.url);
dispatch({
type: SET_PROVISIONING_URL,
payload: event.url,
});
} else if (event.kind === ProvisionEventKind.Envelope) {
const { envelope } = event;
if (signal.aborted) {
log.warn('installer/startInstaller: aborted');
return;
}
// Wait for primary device to scan QR code and get back to us
try {
await provisioner.waitForEnvelope();
} catch (error) {
if (signal.aborted) {
return;
}
log.error(
'installer: got an error while waiting for envelope code',
Errors.toLogFormat(error)
if (event.isLinkAndSync) {
const deviceName = OS.getName() || 'Signal Desktop';
dispatch(
finishInstall({
envelope,
deviceName,
isLinkAndSync: true,
})
);
if (error instanceof InactiveTimeoutError) {
dispatch({
type: SET_ERROR,
payload: InstallScreenError.InactiveTimeout,
});
return;
}
dispatch({
type: SET_ERROR,
payload: InstallScreenError.ConnectionFailed,
});
return;
}
if (signal.aborted) {
return;
}
provisionerByBaton.set(baton, provisioner);
if (provisioner.isLinkAndSync()) {
dispatch(finishInstall({ deviceName: OS.getName() || 'Signal Desktop' }));
} else {
const deviceName =
window.textsecure.storage.user.getDeviceName() ||
window.getHostName() ||
'';
// Show screen to choose device name
dispatch({
type: QR_CODE_SCANNED,
payload: {
deviceName:
window.textsecure.storage.user.getDeviceName() ||
window.getHostName() ||
'',
deviceName,
envelope,
baton,
},
});
@ -350,17 +296,35 @@ function startInstaller(): ThunkAction<
if (SignalCI != null) {
dispatch(
finishInstall({
envelope,
deviceName: SignalCI.deviceName,
isLinkAndSync: false,
})
);
}
}
} else {
throw missingCaseError(event);
}
});
cancelByBaton.set(baton, cancel);
};
}
function finishInstall(
options: PrepareLinkDataOptionsType
): ThunkAction<
type FinishInstallOptionsType = ReadonlyDeep<{
isLinkAndSync: boolean;
deviceName: string;
envelope?: ProvisionEnvelopeType;
backupFile?: Uint8Array;
}>;
function finishInstall({
isLinkAndSync,
envelope: providedEnvelope,
deviceName,
backupFile,
}: FinishInstallOptionsType): ThunkAction<
void,
RootStateType,
unknown,
@ -371,42 +335,46 @@ function finishInstall(
> {
return async (dispatch, getState) => {
const state = getState();
strictAssert(
state.installer.step === InstallScreenStep.ChoosingDeviceName ||
state.installer.step === InstallScreenStep.QrCodeNotScanned,
'Wrong step'
);
const { baton } = state.installer;
const provisioner = provisionerByBaton.get(baton);
strictAssert(
provisioner != null,
'Provisioner is not waiting for device info'
);
let envelope: ProvisionEnvelopeType;
if (state.installer.step === InstallScreenStep.QrCodeNotScanned) {
strictAssert(isLinkAndSync, 'Can only skip device naming if link & sync');
strictAssert(
provisioner.isLinkAndSync(),
'Can only skip device naming if link & sync'
providedEnvelope != null,
'finishInstall: missing required envelope'
);
envelope = providedEnvelope;
} else if (state.installer.step === InstallScreenStep.ChoosingDeviceName) {
({ envelope } = state.installer);
} else {
throw new Error('Wrong step');
}
// Cleanup
controllerByBaton.delete(baton);
provisionerByBaton.delete(baton);
const { baton } = state.installer;
cancelByBaton.delete(baton);
const accountManager = window.getAccountManager();
strictAssert(accountManager, 'Expected an account manager');
if (isBackupEnabled() || provisioner.isLinkAndSync()) {
if (isBackupEnabled() || isLinkAndSync) {
dispatch({ type: SHOW_BACKUP_IMPORT });
} else {
dispatch({ type: SHOW_LINK_IN_PROGRESS });
}
try {
const data = provisioner.prepareLinkData(options);
await accountManager.registerSecondDevice(data);
await accountManager.registerSecondDevice(
Provisioner.prepareLinkData({
envelope,
deviceName,
backupFile,
})
);
window.IPC.removeSetupMenuItems();
} catch (error) {
if (error instanceof HTTPError) {
@ -498,8 +466,11 @@ export function reducer(
if (action.type === START_INSTALLER) {
// Abort previous install
if (state.step === InstallScreenStep.QrCodeNotScanned) {
const controller = controllerByBaton.get(state.baton);
controller?.abort();
const cancel = cancelByBaton.get(state.baton);
cancel?.();
} else {
// Reset qr code fetch attempt count when starting from scratch
provisioner?.reset();
}
return {
@ -508,17 +479,15 @@ export function reducer(
loadingState: LoadingState.Loading,
},
baton: action.payload,
attemptCount:
state.step === InstallScreenStep.QrCodeNotScanned
? state.attemptCount + 1
: 0,
};
}
if (action.type === SET_PROVISIONING_URL) {
if (
state.step !== InstallScreenStep.QrCodeNotScanned ||
state.provisioningUrl.loadingState !== LoadingState.Loading
(state.provisioningUrl.loadingState !== LoadingState.Loading &&
// Rotating
state.provisioningUrl.loadingState !== LoadingState.Loaded)
) {
log.warn('ducks/installer: not setting provisioning url', state.step);
return state;
@ -536,7 +505,11 @@ export function reducer(
if (action.type === SET_QR_CODE_ERROR) {
if (
state.step !== InstallScreenStep.QrCodeNotScanned ||
state.provisioningUrl.loadingState !== LoadingState.Loading
!(
state.provisioningUrl.loadingState === LoadingState.Loading ||
// Rotating
state.provisioningUrl.loadingState === LoadingState.Loaded
)
) {
log.warn('ducks/installer: not setting qr code error', state.step);
return state;
@ -570,6 +543,7 @@ export function reducer(
return {
step: InstallScreenStep.ChoosingDeviceName,
deviceName: action.payload.deviceName,
envelope: action.payload.envelope,
baton: action.payload.baton,
};
}

View file

@ -41,9 +41,17 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
const onSubmitDeviceName = useCallback(async () => {
if (backupFile != null) {
// This is only for testing so don't bother catching errors
finishInstall({ deviceName, backupFile: await fileToBytes(backupFile) });
finishInstall({
deviceName,
backupFile: await fileToBytes(backupFile),
isLinkAndSync: false,
});
} else {
finishInstall({ deviceName, backupFile: undefined });
finishInstall({
deviceName,
backupFile: undefined,
isLinkAndSync: false,
});
}
}, [backupFile, deviceName, finishInstall]);

View file

@ -315,9 +315,3 @@ export class IncorrectSenderKeyAuthError extends Error {}
export class WarnOnlyError extends Error {}
export class NoSenderKeyError extends Error {}
export class InactiveTimeoutError extends Error {
constructor() {
super('Closing socket due to inactivity');
}
}

View file

@ -1,212 +1,157 @@
// Copyright 2024 Signal Messenger, LLC
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
type ExplodePromiseResultType,
explodePromise,
} from '../util/explodePromise';
import { linkDeviceRoute } from '../util/signalRoutes';
import { strictAssert } from '../util/assert';
import { normalizeAci } from '../util/normalizeAci';
import { normalizeDeviceName } from '../util/normalizeDeviceName';
import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled';
import { MINUTE } from '../util/durations';
import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen';
import pTimeout, { TimeoutError as PTimeoutError } from 'p-timeout';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen';
import {
isUntaggedPniString,
normalizePni,
toTaggedPni,
} from '../types/ServiceId';
import { SignalService as Proto } from '../protobuf';
import { strictAssert } from '../util/assert';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
import { SECOND } from '../util/durations';
import { explodePromise } from '../util/explodePromise';
import { drop } from '../util/drop';
import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled';
import { normalizeAci } from '../util/normalizeAci';
import { normalizeDeviceName } from '../util/normalizeDeviceName';
import { linkDeviceRoute } from '../util/signalRoutes';
import { sleep } from '../util/sleep';
import * as Bytes from '../Bytes';
import * as log from '../logging/log';
import { type WebAPIType } from './WebAPI';
import ProvisioningCipher, {
type ProvisionDecryptResult,
} from './ProvisioningCipher';
import { SignalService as Proto } from '../protobuf';
import {
type CreateLinkedDeviceOptionsType,
AccountType,
} from './AccountManager';
import ProvisioningCipher, {
type ProvisionDecryptResult,
} from './ProvisioningCipher';
import {
type IWebSocketResource,
type IncomingWebSocketRequest,
ServerRequestType,
} from './WebsocketResources';
import { InactiveTimeoutError } from './Errors';
import { ConnectTimeoutError } from './Errors';
import { type WebAPIType } from './WebAPI';
enum Step {
Idle = 'Idle',
Connecting = 'Connecting',
WaitingForURL = 'WaitingForURL',
WaitingForEnvelope = 'WaitingForEnvelope',
ReadyToLink = 'ReadyToLink',
Done = 'Done',
export enum EventKind {
MaxRotationsError = 'MaxRotationsError',
TimeoutError = 'TimeoutError',
ConnectError = 'ConnectError',
EnvelopeError = 'EnvelopeError',
URL = 'URL',
Envelope = 'Envelope',
}
type StateType = Readonly<
| {
step: Step.Idle;
}
| {
step: Step.Connecting;
}
| {
step: Step.WaitingForURL;
url: ExplodePromiseResultType<string>;
}
| {
step: Step.WaitingForEnvelope;
done: ExplodePromiseResultType<void>;
}
| {
step: Step.ReadyToLink;
envelope: ProvisionDecryptResult;
}
| {
step: Step.Done;
}
>;
export type PrepareLinkDataOptionsType = Readonly<{
deviceName: string;
backupFile?: Uint8Array;
}>;
export type ProvisionerOptionsType = Readonly<{
server: WebAPIType;
appVersion: string;
}>;
const INACTIVE_SOCKET_TIMEOUT = 30 * MINUTE;
export type EnvelopeType = ProvisionDecryptResult;
export type EventType = Readonly<
| {
kind: EventKind.MaxRotationsError;
}
| {
kind: EventKind.TimeoutError;
canRetry: boolean;
}
| {
kind: EventKind.ConnectError;
error: Error;
}
| {
kind: EventKind.EnvelopeError;
error: Error;
}
| {
kind: EventKind.URL;
url: string;
}
| {
kind: EventKind.Envelope;
envelope: EnvelopeType;
isLinkAndSync: boolean;
}
>;
export type SubscribeNotifierType = (event: EventType) => void;
export type UnsubscribeFunctionType = () => void;
export type SubscriberType = Readonly<{
notify: SubscribeNotifierType;
}>;
export type PrepareLinkDataOptionsType = Readonly<{
envelope: EnvelopeType;
deviceName: string;
backupFile?: Uint8Array;
}>;
enum SocketState {
WaitingForUuid = 'WaitingForUuid',
WaitingForEnvelope = 'WaitingForEnvelope',
Done = 'Done',
}
const ROTATION_INTERVAL = 45 * SECOND;
const MAX_OPEN_SOCKETS = 2;
const MAX_ROTATIONS = 6;
const TIMEOUT_ERROR = new PTimeoutError();
const QR_CODE_TIMEOUTS = [10 * SECOND, 20 * SECOND, 30 * SECOND, 60 * SECOND];
export class Provisioner {
readonly #cipher = new ProvisioningCipher();
readonly #subscribers = new Set<SubscriberType>();
readonly #server: WebAPIType;
readonly #appVersion: string;
#state: StateType = { step: Step.Idle };
#wsr: IWebSocketResource | undefined;
readonly #retryBackOff = new BackOff(FIBONACCI_TIMEOUTS);
constructor(options: ProvisionerOptionsType) {
this.#server = options.server;
this.#appVersion = options.appVersion;
#sockets: Array<IWebSocketResource> = [];
#abortController: AbortController | undefined;
#attemptCount = 0;
#isRunning = false;
constructor({ server, appVersion }: ProvisionerOptionsType) {
this.#server = server;
this.#appVersion = appVersion;
}
public close(error = new Error('Provisioner closed')): void {
try {
this.#wsr?.close();
} catch {
// Best effort
public subscribe(notify: SubscribeNotifierType): UnsubscribeFunctionType {
const subscriber = { notify };
this.#subscribers.add(subscriber);
if (this.#subscribers.size === 1) {
this.#start();
}
const prevState = this.#state;
this.#state = { step: Step.Done };
if (prevState.step === Step.WaitingForURL) {
prevState.url.reject(error);
} else if (prevState.step === Step.WaitingForEnvelope) {
prevState.done.reject(error);
return () => {
this.#subscribers.delete(subscriber);
if (this.#subscribers.size === 0) {
this.#stop('Cancel, no subscribers');
}
}
public async getURL(): Promise<string> {
strictAssert(
this.#state.step === Step.Idle,
`Invalid state for getURL: ${this.#state.step}`
);
this.#state = { step: Step.Connecting };
const wsr = await this.#server.getProvisioningResource({
handleRequest: (request: IncomingWebSocketRequest) => {
try {
this.#handleRequest(request);
} catch (error) {
log.error(
'Provisioner.handleRequest: failure',
Errors.toLogFormat(error)
);
this.close();
}
},
});
this.#wsr = wsr;
let inactiveTimer: NodeJS.Timeout | undefined;
const onVisibilityChange = (): void => {
// Visible
if (!document.hidden) {
if (inactiveTimer != null) {
clearTimeout(inactiveTimer);
}
inactiveTimer = undefined;
return;
}
// Invisible, but already has a timer
if (inactiveTimer != null) {
return;
}
inactiveTimer = setTimeout(() => {
inactiveTimer = undefined;
this.close(new InactiveTimeoutError());
}, INACTIVE_SOCKET_TIMEOUT);
};
document.addEventListener('visibilitychange', onVisibilityChange);
if (this.#state.step !== Step.Connecting) {
this.close();
throw new Error('Provisioner closed early');
}
this.#state = {
step: Step.WaitingForURL,
url: explodePromise(),
};
wsr.addEventListener('close', ({ code, reason }) => {
// Unsubscribe from visibility changes
document.removeEventListener('visibilitychange', onVisibilityChange);
if (inactiveTimer != null) {
clearTimeout(inactiveTimer);
}
inactiveTimer = undefined;
if (this.#state.step === Step.ReadyToLink) {
// WebSocket close is not an issue since we no longer need it
return;
public reset(): void {
this.#attemptCount = 0;
this.#retryBackOff.reset();
}
log.info(`provisioning socket closed. Code: ${code} Reason: ${reason}`);
this.close(new Error('websocket closed'));
});
return this.#state.url.promise;
}
public async waitForEnvelope(): Promise<void> {
strictAssert(
this.#state.step === Step.WaitingForEnvelope,
`Invalid state for waitForEnvelope: ${this.#state.step}`
);
await this.#state.done.promise;
}
public prepareLinkData({
public static prepareLinkData({
envelope,
deviceName,
backupFile,
}: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType {
strictAssert(
this.#state.step === Step.ReadyToLink,
`Invalid state for prepareLinkData: ${this.#state.step}`
);
const { envelope } = this.#state;
this.#state = { step: Step.Done };
const {
number,
provisioningCode,
@ -270,72 +215,263 @@ export class Provisioner {
};
}
public isLinkAndSync(): boolean {
strictAssert(
this.#state.step === Step.ReadyToLink,
`Invalid state for prepareLinkData: ${this.#state.step}`
//
// Private
//
#start(): void {
log.info('Provisioniner: starting');
if (this.#abortController) {
strictAssert(this.#isRunning, 'Must be running to have controller');
this.#abortController.abort();
}
this.#abortController = new AbortController();
this.#isRunning = true;
drop(this.#loop(this.#abortController.signal));
}
#stop(reason: string): void {
if (!this.#isRunning) {
return;
}
log.info(`Provisioniner: stopping, reason=${reason}`);
this.#abortController?.abort();
this.#abortController = undefined;
this.#isRunning = false;
}
async #loop(signal: AbortSignal): Promise<void> {
let rotations = 0;
while (this.#subscribers.size > 0) {
const logId = `Provisioner.loop(${rotations})`;
if (rotations >= MAX_ROTATIONS) {
log.info(`${logId}: exceeded max rotation count`);
this.#notify({
kind: EventKind.MaxRotationsError,
});
this.#stop('Max rotations reached');
break;
}
let delay: number;
try {
const sleepMs = QR_CODE_TIMEOUTS[this.#attemptCount];
// eslint-disable-next-line no-await-in-loop
await this.#connect(signal, sleepMs);
// Successful connect, sleep until rotation time
delay = ROTATION_INTERVAL;
this.reset();
rotations += 1;
log.info(`${logId}: connected, refreshing in ${delay}ms`);
} catch (error) {
// The only active socket has failed, notify subscribers and shutdown
if (this.#sockets.length === 0) {
if (error === TIMEOUT_ERROR || error instanceof ConnectTimeoutError) {
const canRetry = this.#attemptCount < QR_CODE_TIMEOUTS.length - 1;
this.#attemptCount = Math.min(
this.#attemptCount + 1,
QR_CODE_TIMEOUTS.length - 1
);
const { envelope } = this.#state;
this.#notify({
kind: EventKind.TimeoutError,
canRetry,
});
} else {
this.#notify({
kind: EventKind.ConnectError,
error,
});
}
return (
isLinkAndSyncEnabled(this.#appVersion) &&
Bytes.isNotEmpty(envelope.ephemeralBackupKey)
this.#subscribers.clear();
this.#stop('Only socket failed');
break;
}
// At least one more socket is active, retry connecting silently after
// a delay.
delay = this.#retryBackOff.getAndIncrement();
log.error(
`${logId}: failed to connect, retrying in ${delay}ms`,
Errors.toLogFormat(error)
);
}
#handleRequest(request: IncomingWebSocketRequest): void {
const pubKey = this.#cipher.getPublicKey();
if (
request.requestType === ServerRequestType.ProvisioningAddress &&
request.body
) {
try {
// eslint-disable-next-line no-await-in-loop
await sleep(delay, signal);
} catch (error) {
// Sleep aborted
strictAssert(
this.#state.step === Step.WaitingForURL,
`Unexpected provisioning address, state: ${this.#state}`
this.#subscribers.size === 0,
'Aborted with active subscribers'
);
const prevState = this.#state;
this.#state = { step: Step.WaitingForEnvelope, done: explodePromise() };
break;
}
}
}
const proto = Proto.ProvisioningUuid.decode(request.body);
const { uuid } = proto;
strictAssert(uuid, 'Provisioner.getURL: expected a UUID');
async #connect(signal: AbortSignal, timeout: number): Promise<void> {
const cipher = new ProvisioningCipher();
const uuidPromise = explodePromise<string>();
let state = SocketState.WaitingForUuid;
const timeoutAt = Date.now() + timeout;
const resource = await this.#server.getProvisioningResource(
{
handleRequest: (request: IncomingWebSocketRequest) => {
const { requestType, body } = request;
if (!body) {
log.warn('Provisioner.connect: no request body');
request.respond(400, 'Missing body');
return;
}
try {
if (requestType === ServerRequestType.ProvisioningAddress) {
strictAssert(
state === SocketState.WaitingForUuid,
'Provisioner.connect: duplicate uuid'
);
const proto = Proto.ProvisioningUuid.decode(body);
strictAssert(proto.uuid, 'Provisioner.connect: expected a UUID');
state = SocketState.WaitingForEnvelope;
uuidPromise.resolve(proto.uuid);
request.respond(200, 'OK');
} else if (requestType === ServerRequestType.ProvisioningMessage) {
strictAssert(
state === SocketState.WaitingForEnvelope,
'Provisioner.connect: duplicate envelope or not ready'
);
const ciphertext = Proto.ProvisionEnvelope.decode(body);
const envelope = cipher.decrypt(ciphertext);
state = SocketState.Done;
this.#notify({
kind: EventKind.Envelope,
envelope,
isLinkAndSync:
isLinkAndSyncEnabled(this.#appVersion) &&
Bytes.isNotEmpty(envelope.ephemeralBackupKey),
});
request.respond(200, 'OK');
} else {
log.warn(
'Provisioner.connect: unsupported request type',
requestType
);
request.respond(404, 'Unsupported');
}
} catch (error) {
log.error('Provisioner.connect: error', Errors.toLogFormat(error));
resource.close();
}
},
},
timeout
);
if (signal.aborted) {
throw new Error('aborted');
}
// Setup listeners on the socket
const onAbort = () => {
resource.close();
uuidPromise.reject(new Error('aborted'));
};
signal.addEventListener('abort', onAbort);
resource.addEventListener('close', ({ code, reason }) => {
signal.removeEventListener('abort', onAbort);
this.#handleClose(resource, state, code, reason);
});
// But only register it once we get the uuid from server back.
const uuid = await pTimeout(
uuidPromise.promise,
Math.max(0, timeoutAt - Date.now()),
TIMEOUT_ERROR
);
const url = linkDeviceRoute
.toAppUrl({
uuid,
pubKey: Bytes.toBase64(pubKey),
capabilities: isLinkAndSyncEnabled(this.#appVersion)
? ['backup']
: [],
pubKey: Bytes.toBase64(cipher.getPublicKey()),
capabilities: isLinkAndSyncEnabled(this.#appVersion) ? ['backup'] : [],
})
.toString();
window.SignalCI?.setProvisioningURL(url);
prevState.url.resolve(url);
this.#notify({ kind: EventKind.URL, url });
request.respond(200, 'OK');
} else if (
request.requestType === ServerRequestType.ProvisioningMessage &&
request.body
) {
strictAssert(
this.#state.step === Step.WaitingForEnvelope,
`Unexpected provisioning address, state: ${this.#state}`
);
const prevState = this.#state;
this.#sockets.push(resource);
const ciphertext = Proto.ProvisionEnvelope.decode(request.body);
const message = this.#cipher.decrypt(ciphertext);
while (this.#sockets.length > MAX_OPEN_SOCKETS) {
log.info('Provisioner: closing extra socket');
this.#sockets.shift()?.close();
}
}
this.#state = { step: Step.ReadyToLink, envelope: message };
request.respond(200, 'OK');
this.#wsr?.close();
#handleClose(
resource: IWebSocketResource,
state: SocketState,
code: number,
reason: string
): void {
log.info(`Provisioner: socket closed, code=${code}, reason=${reason}`);
prevState.done.resolve();
} else {
log.error('Unknown websocket message', request.requestType);
const index = this.#sockets.indexOf(resource);
if (index === -1) {
return;
}
// Is URL from the socket displayed as a QR code?
const isActive = index === this.#sockets.length - 1;
this.#sockets.splice(index, 1);
// Graceful closure
if (state === SocketState.Done) {
return;
}
if (isActive) {
this.#notify({
kind:
state === SocketState.WaitingForUuid
? EventKind.ConnectError
: EventKind.EnvelopeError,
error: new Error(`Socket closed, code=${code}, reason=${reason}`),
});
}
}
#notify(event: EventType): void {
for (const { notify } of this.#subscribers) {
notify(event);
}
}
}

View file

@ -349,7 +349,8 @@ export class SocketManager extends EventListener {
// Creates new IWebSocketResource for AccountManager's provisioning
public async getProvisioningResource(
handler: IRequestHandler
handler: IRequestHandler,
timeout?: number
): Promise<IWebSocketResource> {
if (this.#isRemotelyExpired) {
throw new Error('Remotely expired, not connecting provisioning socket');
@ -366,6 +367,10 @@ export class SocketManager extends EventListener {
},
keepalive: { path: '/v1/keepalive/provisioning' },
},
extraHeaders: {
'x-signal-websocket-timeout': 'true',
},
timeout,
}).getResult();
}
@ -704,6 +709,7 @@ export class SocketManager extends EventListener {
resourceOptions,
query = {},
extraHeaders = {},
timeout,
}: {
name: string;
path: string;
@ -711,6 +717,7 @@ export class SocketManager extends EventListener {
resourceOptions: WebSocketResourceOptions;
query?: Record<string, string>;
extraHeaders?: Record<string, string>;
timeout?: number;
}): AbortableProcess<IWebSocketResource> {
const queryWithDefaults = {
agent: 'OWD',
@ -728,6 +735,7 @@ export class SocketManager extends EventListener {
version,
certificateAuthority: this.options.certificateAuthority,
proxyAgent,
timeout,
extraHeaders,

View file

@ -1401,7 +1401,8 @@ export type WebAPIType = {
userLanguages: ReadonlyArray<string>
) => Promise<unknown>;
getProvisioningResource: (
handler: IRequestHandler
handler: IRequestHandler,
timeout?: number
) => Promise<IWebSocketResource>;
getSenderCertificate: (
withUuid?: boolean
@ -4588,9 +4589,10 @@ export function initialize({
}
function getProvisioningResource(
handler: IRequestHandler
handler: IRequestHandler,
timeout?: number
): Promise<IWebSocketResource> {
return socketManager.getProvisioningResource(handler);
return socketManager.getProvisioningResource(handler, timeout);
}
async function cdsLookup({

View file

@ -33,6 +33,7 @@ export enum InstallScreenError {
}
export enum InstallScreenQRCodeError {
MaxRotations = 'MaxRotations',
Timeout = 'Timeout',
Unknown = 'Unknown',
NetworkIssue = 'NetworkIssue',