477 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|