From d28e3a783aaf4e628e8cdd0b4acb8a69bb6544e4 Mon Sep 17 00:00:00 2001 From: Alex Bakon Date: Wed, 17 Dec 2025 12:25:37 -0500 Subject: [PATCH] Use libsignal-net typed Chat API for lookup by username Co-authored-by: Scott Nonnenberg --- ts/textsecure/SocketManager.preload.ts | 39 +++++++---- ts/textsecure/WebAPI.preload.ts | 54 +++++++-------- ts/textsecure/WebsocketResources.preload.ts | 69 +++++++++++-------- ...kupConversationWithoutServiceId.preload.ts | 51 +++++--------- 4 files changed, 107 insertions(+), 106 deletions(-) diff --git a/ts/textsecure/SocketManager.preload.ts b/ts/textsecure/SocketManager.preload.ts index 6b9dece6ba..da98a0507f 100644 --- a/ts/textsecure/SocketManager.preload.ts +++ b/ts/textsecure/SocketManager.preload.ts @@ -15,7 +15,8 @@ import EventListener from 'node:events'; import type { IncomingMessage } from 'node:http'; import { setTimeout as sleep } from 'node:timers/promises'; -import type { AbortableProcess } from '../util/AbortableProcess.std.js'; +import type { UnauthUsernamesService } from '@signalapp/libsignal-client/dist/net'; + import { strictAssert } from '../util/assert.std.js'; import { explodePromise } from '../util/explodePromise.std.js'; import { @@ -33,7 +34,10 @@ import * as Errors from '../types/errors.std.js'; import * as Bytes from '../Bytes.std.js'; import { createLogger } from '../logging/log.std.js'; +import type { AbortableProcess } from '../util/AbortableProcess.std.js'; import type { + ChatKind, + IChatConnection, IncomingWebSocketRequest, IWebSocketResource, WebSocketResourceOptions, @@ -97,8 +101,8 @@ export class SocketManager extends EventListener { jitter: JITTER, }); - #authenticated?: AbortableProcess; - #unauthenticated?: AbortableProcess; + #authenticated?: AbortableProcess>; + #unauthenticated?: AbortableProcess>; #unauthenticatedExpirationTimer?: NodeJS.Timeout; #credentials?: WebAPICredentials; #lazyProxyAgent?: Promise; @@ -260,7 +264,7 @@ export class SocketManager extends EventListener { } }; - let authenticated: IWebSocketResource; + let authenticated: IChatConnection<'auth'>; try { authenticated = await process.getResult(); @@ -364,7 +368,7 @@ export class SocketManager extends EventListener { // Either returns currently connecting/active authenticated // IWebSocketResource or connects a fresh one. - public async getAuthenticatedResource(): Promise { + public async getAuthenticatedResource(): Promise> { if (!this.#authenticated) { strictAssert(this.#credentials !== undefined, 'Missing credentials'); await this.authenticate(this.#credentials); @@ -427,13 +431,18 @@ export class SocketManager extends EventListener { }).getResult(); } + public async getUnauthenticatedLibsignalApi(): Promise { + const resource = await this.#getUnauthenticatedResource(); + return resource.libsignalWebsocket; + } + // Fetch-compatible wrapper around underlying unauthenticated/authenticated // websocket resources. This wrapper supports only limited number of features // of node-fetch despite being API compatible. public async fetch(url: string, init: RequestInit): Promise { const headers = new Headers(init.headers); - let resource: IWebSocketResource; + let resource: IChatConnection<'auth'> | IChatConnection<'unauth'>; if (this.#isAuthenticated(headers)) { resource = await this.getAuthenticatedResource(); } else { @@ -618,7 +627,7 @@ export class SocketManager extends EventListener { } } - async #getUnauthenticatedResource(): Promise { + async #getUnauthenticatedResource(): Promise> { if (this.#expirationReason) { throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, { code: 0, @@ -642,7 +651,7 @@ export class SocketManager extends EventListener { window.SignalContext.getResolvedMessagesLocale() ); - const process: AbortableProcess = + const process: AbortableProcess> = connectUnauthenticatedLibsignal({ libsignalNet: this.libsignalNet, name: UNAUTHENTICATED_CHANNEL_NAME, @@ -652,7 +661,7 @@ export class SocketManager extends EventListener { this.#unauthenticated = process; - let unauthenticated: IWebSocketResource; + let unauthenticated: IChatConnection<'unauth'>; try { unauthenticated = await this.#unauthenticated.getResult(); this.#setUnauthenticatedStatus({ @@ -771,8 +780,8 @@ export class SocketManager extends EventListener { return webSocketResourceConnection; } - async #checkResource( - process?: AbortableProcess + async #checkResource( + process?: AbortableProcess> ): Promise { if (!process) { return; @@ -786,7 +795,7 @@ export class SocketManager extends EventListener { ); } - #dropAuthenticated(process: AbortableProcess): void { + #dropAuthenticated(process: AbortableProcess>): void { if (this.#authenticated !== process) { return; } @@ -807,7 +816,9 @@ export class SocketManager extends EventListener { } } - #dropUnauthenticated(process: AbortableProcess): void { + #dropUnauthenticated( + process: AbortableProcess> + ): void { if (this.#unauthenticated !== process) { return; } @@ -822,7 +833,7 @@ export class SocketManager extends EventListener { } async #startUnauthenticatedExpirationTimer( - expected: IWebSocketResource + expected: IChatConnection<'unauth'> ): Promise { const process = this.#unauthenticated; strictAssert( diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index be49a73d24..fe849e4580 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -53,6 +53,7 @@ import type { UntaggedPniString, } from '../types/ServiceId.std.js'; import { + fromAciObject, ServiceIdKind, serviceIdSchema, aciSchema, @@ -628,27 +629,29 @@ async function _promiseAjax( return result; } -async function _retryAjax( - url: string | null, - options: PromiseAjaxOptionsType, - providedLimit?: number, - providedCount?: number -): Promise { - const count = (providedCount || 0) + 1; - const limit = providedLimit || 3; +async function _retry( + f: () => Promise, + provided?: { abortSignal?: AbortSignal } & ( + | { limit: number; count: number } + | { limit?: undefined; count?: undefined } + ) +): Promise { + const count = (provided?.count ?? 0) + 1; + const limit = provided?.limit ?? 3; + const abortSignal = provided?.abortSignal; try { - return await _promiseAjax(url, options); + return await f(); } catch (e) { if ( e instanceof HTTPError && e.code === -1 && count < limit && - !options.abortSignal?.aborted + !abortSignal?.aborted ) { return new Promise(resolve => { setTimeout(() => { - resolve(_retryAjax(url, options, limit, count)); + resolve(_retry(f, { abortSignal, limit, count })); }, 1000); }); } @@ -681,7 +684,9 @@ async function _outerAjax( return _promiseAjax(url, options); } - return _retryAjax(url, options); + return _retry(() => _promiseAjax(url, options), { + abortSignal: options.abortSignal, + }); } function makeHTTPError( @@ -944,13 +949,7 @@ export type GetAccountForUsernameOptionsType = Readonly<{ hash: Uint8Array; }>; -const getAccountForUsernameResultZod = z.object({ - uuid: aciSchema, -}); - -export type GetAccountForUsernameResultType = z.infer< - typeof getAccountForUsernameResultZod ->; +export type GetAccountForUsernameResultType = AciString | null; const getDevicesResultZod = z.object({ devices: z.array( @@ -2450,19 +2449,12 @@ export async function getTransferArchive({ export async function getAccountForUsername({ hash, }: GetAccountForUsernameOptionsType): Promise { - const hashBase64 = toWebSafeBase64(Bytes.toBase64(hash)); - return _ajax({ - host: 'chatService', - call: 'username', - httpType: 'GET', - urlParameters: `/${hashBase64}`, - responseType: 'json', - redactUrl: _createRedactor(hashBase64), - unauthenticated: true, - accessKey: undefined, - groupSendToken: undefined, - zodSchema: getAccountForUsernameResultZod, + const aci = await _retry(async () => { + const chat = await socketManager.getUnauthenticatedLibsignalApi(); + return chat.lookUpUsernameHash({ hash }); }); + + return aci ? fromAciObject(aci) : null; } export async function putProfile( diff --git a/ts/textsecure/WebsocketResources.preload.ts b/ts/textsecure/WebsocketResources.preload.ts index b9ca3dc207..c233ec916f 100644 --- a/ts/textsecure/WebsocketResources.preload.ts +++ b/ts/textsecure/WebsocketResources.preload.ts @@ -37,9 +37,11 @@ import type { LibSignalError, Net } from '@signalapp/libsignal-client'; import { ErrorCode } from '@signalapp/libsignal-client'; import { Buffer } from 'node:buffer'; import type { + AuthenticatedChatConnection, ChatServerMessageAck, ChatServiceListener, ConnectionEventsListener, + UnauthenticatedChatConnection, } from '@signalapp/libsignal-client/dist/net/Chat.js'; import type { EventHandler } from './EventTarget.std.js'; import EventTarget from './EventTarget.std.js'; @@ -281,6 +283,12 @@ export class CloseEvent extends Event { } } +export type ChatKind = 'auth' | 'unauth'; + +type LibsignalChatConnection = Kind extends 'auth' + ? AuthenticatedChatConnection + : UnauthenticatedChatConnection; + // eslint-disable-next-line no-restricted-syntax export interface IWebSocketResource extends IResource { sendRequest(options: SendRequestOptions): Promise; @@ -296,8 +304,12 @@ export interface IWebSocketResource extends IResource { localPort(): number | undefined; } -type LibsignalWebSocketResourceHolder = { - resource: LibsignalWebSocketResource | undefined; +export type IChatConnection = IWebSocketResource & { + get libsignalWebsocket(): LibsignalChatConnection; +}; + +type LibsignalWebSocketResourceHolder = { + resource: LibsignalWebSocketResource | undefined; }; const UNEXPECTED_DISCONNECT_CODE = 3001; @@ -312,20 +324,20 @@ export function connectUnauthenticatedLibsignal({ name: string; userLanguages: ReadonlyArray; keepalive: KeepAliveOptionsType; -}): AbortableProcess { +}): AbortableProcess> { const logId = `LibsignalWebSocketResource(${name})`; - const listener: LibsignalWebSocketResourceHolder & ConnectionEventsListener = - { - resource: undefined, - onConnectionInterrupted(cause: LibSignalError | null): void { - if (!this.resource) { - logDisconnectedListenerWarn(logId, 'onConnectionInterrupted'); - return; - } - this.resource.onConnectionInterrupted(cause); - this.resource = undefined; - }, - }; + const listener: LibsignalWebSocketResourceHolder<'unauth'> & + ConnectionEventsListener = { + resource: undefined, + onConnectionInterrupted(cause: LibSignalError | null): void { + if (!this.resource) { + logDisconnectedListenerWarn(logId, 'onConnectionInterrupted'); + return; + } + this.resource.onConnectionInterrupted(cause); + this.resource = undefined; + }, + }; return connectLibsignal( abortSignal => libsignalNet.connectUnauthenticatedChat(listener, { @@ -356,9 +368,10 @@ export function connectAuthenticatedLibsignal({ receiveStories: boolean; userLanguages: ReadonlyArray; keepalive: KeepAliveOptionsType; -}): AbortableProcess { +}): AbortableProcess> { const logId = `LibsignalWebSocketResource(${name})`; - const listener: LibsignalWebSocketResourceHolder & ChatServiceListener = { + const listener: LibsignalWebSocketResourceHolder<'auth'> & + ChatServiceListener = { resource: undefined, onIncomingMessage( envelope: Uint8Array, @@ -418,16 +431,14 @@ function logDisconnectedListenerWarn(logId: string, method: string): void { log.warn(`${logId} received ${method}, but listener already disconnected`); } -function connectLibsignal( +function connectLibsignal( makeConnection: ( abortSignal: AbortSignal - ) => Promise< - Net.UnauthenticatedChatConnection | Net.AuthenticatedChatConnection - >, - resourceHolder: LibsignalWebSocketResourceHolder, + ) => Promise>, + resourceHolder: LibsignalWebSocketResourceHolder, logId: string, keepalive: KeepAliveOptionsType -): AbortableProcess { +): AbortableProcess> { const abortController = new AbortController(); const connectAsync = async () => { try { @@ -454,7 +465,7 @@ function connectLibsignal( throw error; } }; - return new AbortableProcess( + return new AbortableProcess>( `${logId}.connect`, { abort() { @@ -470,9 +481,9 @@ function connectLibsignal( ); } -export class LibsignalWebSocketResource +export class LibsignalWebSocketResource extends EventTarget - implements IWebSocketResource + implements IChatConnection { // The reason that the connection was closed, if it was closed. // @@ -487,7 +498,7 @@ export class LibsignalWebSocketResource #keepalive: KeepAlive; constructor( - private readonly chatService: Net.ChatConnection, + private readonly chatService: LibsignalChatConnection, private readonly socketIpVersion: IpVersion, private readonly localPortNumber: number, private readonly logId: string, @@ -576,6 +587,10 @@ export class LibsignalWebSocketResource return response; } + get libsignalWebsocket(): LibsignalChatConnection { + return this.chatService; + } + public async sendRequestGetDebugInfo( options: SendRequestOptions ): Promise { diff --git a/ts/util/lookupConversationWithoutServiceId.preload.ts b/ts/util/lookupConversationWithoutServiceId.preload.ts index d99c559c2e..e0f6f96a3a 100644 --- a/ts/util/lookupConversationWithoutServiceId.preload.ts +++ b/ts/util/lookupConversationWithoutServiceId.preload.ts @@ -1,21 +1,20 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { usernames, LibSignalErrorBase } from '@signalapp/libsignal-client'; +import { usernames } from '@signalapp/libsignal-client'; import type { UserNotFoundModalStateType } from '../state/ducks/globalModals.preload.js'; import { createLogger } from '../logging/log.std.js'; import type { AciString } from '../types/ServiceId.std.js'; +import * as Errors from '../types/errors.std.js'; +import { ToastType } from '../types/Toast.dom.js'; +import { strictAssert } from './assert.std.js'; +import type { UUIDFetchStateKeyType } from './uuidFetchState.std.js'; +import { getServiceIdsForE164s } from './getServiceIdsForE164s.dom.js'; import { getAccountForUsername, cdsLookup, } from '../textsecure/WebAPI.preload.js'; -import * as Errors from '../types/errors.std.js'; -import { ToastType } from '../types/Toast.dom.js'; -import { HTTPError } from '../types/HTTPError.std.js'; -import { strictAssert } from './assert.std.js'; -import type { UUIDFetchStateKeyType } from './uuidFetchState.std.js'; -import { getServiceIdsForE164s } from './getServiceIdsForE164s.dom.js'; const log = createLogger('lookupConversationWithoutServiceId'); @@ -155,33 +154,17 @@ export async function checkForUsername( return undefined; } - try { - const account = await getAccountForUsername({ - hash, - }); + const accountAci = await getAccountForUsername({ + hash, + }); - if (!account.uuid) { - log.error("checkForUsername: Returned account didn't include a uuid"); - return; - } - - return { - aci: account.uuid, - username: fixedUsername, - }; - } catch (error) { - if (error instanceof HTTPError) { - if (error.code === 404) { - return undefined; - } - } - - // Invalid username - if (error instanceof LibSignalErrorBase) { - log.error('checkForUsername: invalid username'); - return undefined; - } - - throw error; + if (!accountAci) { + log.error("checkForUsername: Returned account didn't include a uuid"); + return; } + + return { + aci: accountAci, + username: fixedUsername, + }; }