diff --git a/ts/background.ts b/ts/background.ts index 98a05aa3b34..3437c08267c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -204,6 +204,7 @@ import { getParametersForRedux, loadAll } from './services/allLoaders'; import { checkFirstEnvelope } from './util/checkFirstEnvelope'; import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked'; import { ReleaseNotesFetcher } from './services/releaseNotesFetcher'; +import { BuildExpirationService } from './services/buildExpiration'; import { maybeQueueDeviceNameFetch, onDeviceNameChangeSync, @@ -522,11 +523,18 @@ export async function startApp(): Promise { window.Whisper.events.on('firstEnvelope', checkFirstEnvelope); + const buildExpirationService = new BuildExpirationService(); + server = window.WebAPI.connect({ ...window.textsecure.storage.user.getWebAPICredentials(), + hasBuildExpired: buildExpirationService.hasBuildExpired(), hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false), }); + buildExpirationService.on('expired', () => { + drop(server?.onExpiration('build')); + }); + window.textsecure.server = server; window.textsecure.messaging = new window.textsecure.MessageSender(server); @@ -1428,7 +1436,7 @@ export async function startApp(): Promise { log.error('background: remote expiration detected, disabling reconnects'); drop(window.storage.put('remoteBuildExpiration', Date.now())); - drop(server?.onRemoteExpiration()); + drop(server?.onExpiration('remote')); remotelyExpired = true; }); @@ -1717,6 +1725,7 @@ export async function startApp(): Promise { if (remotelyExpired) { log.info('afterAuthSocketConnect: remotely expired'); + drop(onEmpty({ isFromMessageReceiver: false })); // this ensures that the inbox loading progress bar is dismissed return; } diff --git a/ts/services/buildExpiration.ts b/ts/services/buildExpiration.ts new file mode 100644 index 00000000000..996567886b8 --- /dev/null +++ b/ts/services/buildExpiration.ts @@ -0,0 +1,89 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import EventEmitter from 'node:events'; + +import { + hasBuildExpired, + getBuildExpirationTimestamp, +} from '../util/buildExpiration'; +import { LongTimeout } from '../util/timeout'; +import * as log from '../logging/log'; + +export class BuildExpirationService extends EventEmitter { + constructor() { + super(); + + // Let API users subscribe to `expired` event before firing it. + queueMicrotask(() => this.#startTimer()); + } + + hasBuildExpired(): boolean { + const autoDownloadUpdate = window.storage.get('auto-download-update', true); + + return hasBuildExpired({ + buildExpirationTimestamp: this.#getBuildExpirationTimestamp(), + autoDownloadUpdate, + now: Date.now(), + logger: log, + }); + } + + // Private + + #getBuildExpirationTimestamp(): number { + const autoDownloadUpdate = window.storage.get('auto-download-update', true); + + return getBuildExpirationTimestamp({ + version: window.getVersion(), + packagedBuildExpiration: window.getBuildExpiration(), + remoteBuildExpiration: window.storage.get('remoteBuildExpiration'), + autoDownloadUpdate, + logger: log, + }); + } + + #startTimer(): void { + const timestamp = this.#getBuildExpirationTimestamp(); + const now = Date.now(); + if (timestamp <= now) { + if (this.hasBuildExpired()) { + log.warn('buildExpirationService: expired'); + this.emit('expired'); + } + return; + } + + const delayMs = timestamp - now; + log.info(`buildExpirationService: expires in ${delayMs}ms`); + + // eslint-disable-next-line no-new + new LongTimeout(() => { + if (this.hasBuildExpired()) { + log.warn('buildExpirationService: expired'); + this.emit('expired'); + } else { + this.#startTimer(); + } + }, delayMs); + } + + // EventEmitter types + + public override on(type: 'expired', callback: () => void): this; + + public override on( + type: string | symbol, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: Array) => void + ): this { + return super.on(type, listener); + } + + public override emit(type: 'expired'): boolean; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public override emit(type: string | symbol, ...args: Array): boolean { + return super.emit(type, ...args); + } +} diff --git a/ts/state/selectors/expiration.ts b/ts/state/selectors/expiration.ts index 3b577297936..092bf9cc0f7 100644 --- a/ts/state/selectors/expiration.ts +++ b/ts/state/selectors/expiration.ts @@ -3,18 +3,14 @@ import { createSelector } from 'reselect'; -import { Environment, getEnvironment } from '../../environment'; -import { isInPast } from '../../util/timestamp'; -import { DAY } from '../../util/durations'; import * as log from '../../logging/log'; import type { StateType } from '../reducer'; import type { ExpirationStateType } from '../ducks/expiration'; import { getRemoteBuildExpiration, getAutoDownloadUpdate } from './items'; -import { isNotUpdatable } from '../../util/version'; - -const NINETY_ONE_DAYS = 91 * DAY; -const THIRTY_ONE_DAYS = 31 * DAY; -const SIXTY_DAYS = 60 * DAY; +import { + getBuildExpirationTimestamp, + hasBuildExpired, +} from '../../util/buildExpiration'; export const getExpiration = (state: StateType): ExpirationStateType => state.expiration; @@ -29,28 +25,17 @@ export const getExpirationTimestamp = createSelector( getRemoteBuildExpiration, getAutoDownloadUpdate, ( - buildExpiration: number, + packagedBuildExpiration: number, remoteBuildExpiration: number | undefined, autoDownloadUpdate: boolean ): number => { - const localBuildExpiration = - isNotUpdatable(window.getVersion()) || autoDownloadUpdate - ? buildExpiration - : buildExpiration - SIXTY_DAYS; - - // Log the expiration date in this selector because it invalidates only - // if one of the arguments changes. - let result: number; - let type: string; - if (remoteBuildExpiration && remoteBuildExpiration < localBuildExpiration) { - type = 'remote'; - result = remoteBuildExpiration; - } else { - type = 'local'; - result = localBuildExpiration; - } - log.info(`Build expires (${type}): ${new Date(result).toISOString()}`); - return result; + return getBuildExpirationTimestamp({ + version: window.getVersion(), + packagedBuildExpiration, + remoteBuildExpiration, + autoDownloadUpdate, + logger: log, + }); } ); @@ -62,29 +47,16 @@ export const hasExpired = createSelector( getExpirationTimestamp, getAutoDownloadUpdate, (_: StateType, { now = Date.now() }: HasExpiredOptionsType = {}) => now, - (buildExpiration: number, autoDownloadUpdate: boolean, now: number) => { - if (getEnvironment() !== Environment.PackagedApp && buildExpiration === 0) { - return false; - } - - if (isInPast(buildExpiration)) { - return true; - } - - const safeExpirationMs = autoDownloadUpdate - ? NINETY_ONE_DAYS - : THIRTY_ONE_DAYS; - - const buildExpirationDuration = buildExpiration - now; - const tooFarIntoFuture = buildExpirationDuration > safeExpirationMs; - - if (tooFarIntoFuture) { - log.error( - 'Build expiration is set too far into the future', - buildExpiration - ); - } - - return tooFarIntoFuture || isInPast(buildExpiration); + ( + buildExpirationTimestamp: number, + autoDownloadUpdate: boolean, + now: number + ) => { + return hasBuildExpired({ + buildExpirationTimestamp, + autoDownloadUpdate, + now, + logger: log, + }); } ); diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index ea0b7c68c85..7e2711512da 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -89,6 +89,8 @@ export type SocketStatuses = Record< SocketInfo >; +export type SocketExpirationReason = 'remote' | 'build'; + // This class manages two websocket resources: // // - Authenticated IWebSocketResource which uses supplied WebAPICredentials and @@ -123,7 +125,7 @@ export class SocketManager extends EventListener { #incomingRequestQueue = new Array(); #isNavigatorOffline = false; #privIsOnline: boolean | undefined; - #isRemotelyExpired = false; + #expirationReason: SocketExpirationReason | undefined; #hasStoriesDisabled: boolean; #reconnectController: AbortController | undefined; #envelopeCount = 0; @@ -145,17 +147,29 @@ export class SocketManager extends EventListener { } #markOffline() { - if (this.#privIsOnline !== false) { - this.#privIsOnline = false; - this.emit('offline'); + // Note: `#privIsOnline` starts as `undefined` so that we emit the first + // `offline` event. + if (this.#privIsOnline === false) { + return; } + + this.#privIsOnline = false; + this.emit('offline'); + } + + #markOnline() { + if (this.#privIsOnline === true) { + return; + } + this.#privIsOnline = true; + this.emit('online'); } // Update WebAPICredentials and reconnect authenticated resource if // credentials changed public async authenticate(credentials: WebAPICredentials): Promise { - if (this.#isRemotelyExpired) { - throw new HTTPError('SocketManager remotely expired', { + if (this.#expirationReason != null) { + throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, { code: 0, headers: {}, stack: new Error().stack, @@ -240,8 +254,11 @@ export class SocketManager extends EventListener { this.#authenticated = process; const reconnect = async (): Promise => { - if (this.#isRemotelyExpired) { - log.info('SocketManager: remotely expired, not reconnecting'); + if (this.#expirationReason != null) { + log.info( + `SocketManager: ${this.#expirationReason} expired, ` + + 'not reconnecting' + ); return; } @@ -409,8 +426,11 @@ export class SocketManager extends EventListener { handler: IRequestHandler, timeout?: number ): Promise { - if (this.#isRemotelyExpired) { - throw new Error('Remotely expired, not connecting provisioning socket'); + if (this.#expirationReason != null) { + throw new Error( + `${this.#expirationReason} expired, ` + + 'not connecting provisioning socket' + ); } return this.#connectResource({ @@ -597,12 +617,15 @@ export class SocketManager extends EventListener { await this.check(); } - public async onRemoteExpiration(): Promise { - log.info('SocketManager.onRemoteExpiration'); - this.#isRemotelyExpired = true; + public async onExpiration(reason: SocketExpirationReason): Promise { + log.info('SocketManager.onRemoteExpiration', reason); + this.#expirationReason = reason; // Cancel reconnect attempt if any this.#reconnectController?.abort(); + + // Logout + await this.logout(); } public async logout(): Promise { @@ -636,10 +659,7 @@ export class SocketManager extends EventListener { this.#authenticatedStatus.lastConnectionTransport = newStatus.transportOption; - if (!this.#privIsOnline) { - this.#privIsOnline = true; - this.emit('online'); - } + this.#markOnline(); } } @@ -682,6 +702,14 @@ export class SocketManager extends EventListener { } async #getUnauthenticatedResource(): Promise { + if (this.#expirationReason) { + throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, { + code: 0, + headers: {}, + stack: new Error().stack, + }); + } + // awaiting on `this.getProxyAgent()` needs to happen here // so that there are no calls to `await` between checking // the value of `this.unauthenticated` and assigning it later in this function @@ -691,14 +719,6 @@ export class SocketManager extends EventListener { return this.#unauthenticated.getResult(); } - if (this.#isRemotelyExpired) { - throw new HTTPError('SocketManager remotely expired', { - code: 0, - headers: {}, - stack: new Error().stack, - }); - } - log.info('SocketManager: connecting unauthenticated socket'); const transportOption = this.#transportOption(); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index db64ce67ec7..736c242a160 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -56,7 +56,11 @@ import { getRandomBytes, randomInt } from '../Crypto'; import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch'; import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid'; -import { SocketManager, type SocketStatuses } from './SocketManager'; +import { + SocketManager, + type SocketStatuses, + type SocketExpirationReason, +} from './SocketManager'; import type { CDSAuthType, CDSResponseType } from './cds/Types.d'; import { CDSI } from './cds/CDSI'; import { SignalService as Proto } from '../protobuf'; @@ -87,6 +91,7 @@ import { isProduction } from '../util/version'; import type { ServerAlert } from '../util/handleServerAlerts'; import { isAbortError } from '../util/isAbortError'; import { missingCaseError } from '../util/missingCaseError'; +import { drop } from '../util/drop'; // 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 @@ -817,6 +822,7 @@ type AjaxOptionsType = ( export type WebAPIConnectOptionsType = WebAPICredentials & { hasStoriesDisabled: boolean; + hasBuildExpired: boolean; }; export type WebAPIConnectType = { @@ -1700,7 +1706,7 @@ export type WebAPIType = { isOnline: () => boolean | undefined; onNavigatorOnline: () => Promise; onNavigatorOffline: () => Promise; - onRemoteExpiration: () => Promise; + onExpiration: (reason: SocketExpirationReason) => Promise; reconnect: () => Promise; }; @@ -1880,6 +1886,7 @@ export function initialize({ username: initialUsername, password: initialPassword, hasStoriesDisabled, + hasBuildExpired, }: WebAPIConnectOptionsType) { let username = initialUsername; let password = initialPassword; @@ -1933,7 +1940,11 @@ export function initialize({ serverAlerts = alerts; }); - void socketManager.authenticate({ username, password }); + if (hasBuildExpired) { + drop(socketManager.onExpiration('build')); + } + + drop(socketManager.authenticate({ username, password })); const cds = new CDSI(libsignalNet, { logger: log, @@ -2071,7 +2082,7 @@ export function initialize({ isOnline, onNavigatorOffline, onNavigatorOnline, - onRemoteExpiration, + onExpiration, postBatchIdentityCheck, putEncryptedAttachment, putProfile, @@ -2283,8 +2294,8 @@ export function initialize({ await socketManager.onNavigatorOffline(); } - async function onRemoteExpiration(): Promise { - await socketManager.onRemoteExpiration(); + async function onExpiration(reason: SocketExpirationReason): Promise { + await socketManager.onExpiration(reason); } async function reconnect(): Promise { diff --git a/ts/util/buildExpiration.ts b/ts/util/buildExpiration.ts new file mode 100644 index 00000000000..d3594ea208d --- /dev/null +++ b/ts/util/buildExpiration.ts @@ -0,0 +1,88 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { Environment, getEnvironment } from '../environment'; +import type { LoggerType } from '../types/Logging'; +import { isNotUpdatable } from './version'; +import { isInPast } from './timestamp'; +import { DAY } from './durations'; + +const NINETY_ONE_DAYS = 91 * DAY; +const THIRTY_ONE_DAYS = 31 * DAY; +const SIXTY_DAYS = 60 * DAY; + +export type GetBuildExpirationTimestampOptionsType = Readonly<{ + version: string; + packagedBuildExpiration: number; + remoteBuildExpiration: number | undefined; + autoDownloadUpdate: boolean; + logger: LoggerType; +}>; + +export function getBuildExpirationTimestamp({ + version, + packagedBuildExpiration, + remoteBuildExpiration, + autoDownloadUpdate, + logger, +}: GetBuildExpirationTimestampOptionsType): number { + const localBuildExpiration = + isNotUpdatable(version) || autoDownloadUpdate + ? packagedBuildExpiration + : packagedBuildExpiration - SIXTY_DAYS; + + // Log the expiration date in this selector because it invalidates only + // if one of the arguments changes. + let result: number; + let type: string; + if (remoteBuildExpiration && remoteBuildExpiration < localBuildExpiration) { + type = 'remote'; + result = remoteBuildExpiration; + } else { + type = 'local'; + result = localBuildExpiration; + } + logger.info(`Build expires (${type}): ${new Date(result).toISOString()}`); + return result; +} + +export type HasBuildExpiredOptionsType = Readonly<{ + buildExpirationTimestamp: number; + autoDownloadUpdate: boolean; + now: number; + logger: LoggerType; +}>; + +export function hasBuildExpired({ + buildExpirationTimestamp, + autoDownloadUpdate, + now, + logger, +}: HasBuildExpiredOptionsType): boolean { + if ( + getEnvironment() !== Environment.PackagedApp && + buildExpirationTimestamp === 0 + ) { + return false; + } + + if (isInPast(buildExpirationTimestamp)) { + return true; + } + + const safeExpirationMs = autoDownloadUpdate + ? NINETY_ONE_DAYS + : THIRTY_ONE_DAYS; + + const buildExpirationDuration = buildExpirationTimestamp - now; + const tooFarIntoFuture = buildExpirationDuration > safeExpirationMs; + + if (tooFarIntoFuture) { + logger.error( + 'Build expiration is set too far into the future', + buildExpirationTimestamp + ); + } + + return tooFarIntoFuture || isInPast(buildExpirationTimestamp); +} diff --git a/ts/util/timeout.ts b/ts/util/timeout.ts index c1732872376..e23001afb68 100644 --- a/ts/util/timeout.ts +++ b/ts/util/timeout.ts @@ -34,3 +34,54 @@ export function safeSetTimeout( return setTimeout(callback, delayMs); } + +// Set timeout for a delay that might be longer than MAX_SAFE_TIMEOUT_DELAY. The +// callback is guaranteed to execute after desired delay. +export class LongTimeout { + #callback: VoidFunction; + #fireTime: number; + #timer: NodeJS.Timeout | undefined; + + constructor(callback: VoidFunction, providedDelayMs: number) { + let delayMs = providedDelayMs; + + if (delayMs < 0) { + logging.warn('safeSetTimeout: timeout is less than zero'); + delayMs = 0; + } + if (Number.isNaN(delayMs)) { + throw new Error('NaN delayMs'); + } + if (!Number.isFinite(delayMs)) { + throw new Error('Infinite delayMs'); + } + + this.#callback = callback; + this.#fireTime = Date.now() + delayMs; + this.#schedule(); + } + + clear(): void { + if (this.#timer != null) { + clearTimeout(this.#timer); + } + this.#timer = undefined; + } + + #schedule(): void { + const remainingMs = this.#fireTime - Date.now(); + if (remainingMs <= MAX_SAFE_TIMEOUT_DELAY) { + this.#timer = setTimeout(() => this.#fire(), remainingMs); + return; + } + + this.#timer = setTimeout(() => { + this.#schedule(); + }, MAX_SAFE_TIMEOUT_DELAY); + } + + #fire(): void { + this.clear(); + this.#callback(); + } +}