signal-desktop/ts/textsecure/Provisioner.ts
2025-01-14 12:14:32 -08:00

477 lines
12 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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 { 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 { 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 { ConnectTimeoutError } from './Errors';
import { type WebAPIType } from './WebAPI';
export enum EventKind {
MaxRotationsError = 'MaxRotationsError',
TimeoutError = 'TimeoutError',
ConnectError = 'ConnectError',
EnvelopeError = 'EnvelopeError',
URL = 'URL',
Envelope = 'Envelope',
}
export type ProvisionerOptionsType = Readonly<{
server: WebAPIType;
appVersion: string;
}>;
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 #subscribers = new Set<SubscriberType>();
readonly #server: WebAPIType;
readonly #appVersion: string;
readonly #retryBackOff = new BackOff(FIBONACCI_TIMEOUTS);
#sockets: Array<IWebSocketResource> = [];
#abortController: AbortController | undefined;
#attemptCount = 0;
#isRunning = false;
constructor({ server, appVersion }: ProvisionerOptionsType) {
this.#server = server;
this.#appVersion = appVersion;
}
public subscribe(notify: SubscribeNotifierType): UnsubscribeFunctionType {
const subscriber = { notify };
this.#subscribers.add(subscriber);
if (this.#subscribers.size === 1) {
this.#start();
}
return () => {
this.#subscribers.delete(subscriber);
if (this.#subscribers.size === 0) {
this.#stop('Cancel, no subscribers');
}
};
}
public reset(): void {
this.#attemptCount = 0;
this.#retryBackOff.reset();
}
public static prepareLinkData({
envelope,
deviceName,
backupFile,
}: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType {
const {
number,
provisioningCode,
aciKeyPair,
pniKeyPair,
aci,
profileKey,
masterKey,
untaggedPni,
userAgent,
readReceipts,
ephemeralBackupKey,
accountEntropyPool,
mediaRootBackupKey,
} = envelope;
strictAssert(number, 'prepareLinkData: missing number');
strictAssert(provisioningCode, 'prepareLinkData: missing provisioningCode');
strictAssert(aciKeyPair, 'prepareLinkData: missing aciKeyPair');
strictAssert(pniKeyPair, 'prepareLinkData: missing pniKeyPair');
strictAssert(aci, 'prepareLinkData: missing aci');
strictAssert(
Bytes.isNotEmpty(profileKey),
'prepareLinkData: missing profileKey'
);
strictAssert(
Bytes.isNotEmpty(masterKey) || accountEntropyPool,
'prepareLinkData: missing masterKey or accountEntropyPool'
);
strictAssert(
isUntaggedPniString(untaggedPni),
'prepareLinkData: invalid untaggedPni'
);
const ourAci = normalizeAci(aci, 'provisionMessage.aci');
const ourPni = normalizePni(
toTaggedPni(untaggedPni),
'provisionMessage.pni'
);
return {
type: AccountType.Linked,
number,
verificationCode: provisioningCode,
aciKeyPair,
pniKeyPair,
profileKey,
deviceName: normalizeDeviceName(deviceName).slice(
0,
MAX_DEVICE_NAME_LENGTH
),
backupFile,
userAgent,
ourAci,
ourPni,
readReceipts: Boolean(readReceipts),
masterKey,
ephemeralBackupKey,
accountEntropyPool,
mediaRootBackupKey,
};
}
//
// 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
);
this.#notify({
kind: EventKind.TimeoutError,
canRetry,
});
} else {
this.#notify({
kind: EventKind.ConnectError,
error,
});
}
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)
);
}
try {
// eslint-disable-next-line no-await-in-loop
await sleep(delay, signal);
} catch (error) {
// Sleep aborted
strictAssert(
this.#subscribers.size === 0,
'Aborted with active subscribers'
);
break;
}
}
}
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(cipher.getPublicKey()),
capabilities: isLinkAndSyncEnabled(this.#appVersion) ? ['backup'] : [],
})
.toString();
this.#notify({ kind: EventKind.URL, url });
this.#sockets.push(resource);
while (this.#sockets.length > MAX_OPEN_SOCKETS) {
log.info('Provisioner: closing extra socket');
this.#sockets.shift()?.close();
}
}
#handleClose(
resource: IWebSocketResource,
state: SocketState,
code: number,
reason: string
): void {
log.info(`Provisioner: socket closed, code=${code}, reason=${reason}`);
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);
}
}
}