Use libsignal-net typed Chat API for lookup by username

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Alex Bakon
2025-12-17 12:25:37 -05:00
committed by GitHub
parent ec9a31007b
commit d28e3a783a
4 changed files with 107 additions and 106 deletions

View File

@@ -15,7 +15,8 @@ import EventListener from 'node:events';
import type { IncomingMessage } from 'node:http'; import type { IncomingMessage } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises'; 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 { strictAssert } from '../util/assert.std.js';
import { explodePromise } from '../util/explodePromise.std.js'; import { explodePromise } from '../util/explodePromise.std.js';
import { import {
@@ -33,7 +34,10 @@ import * as Errors from '../types/errors.std.js';
import * as Bytes from '../Bytes.std.js'; import * as Bytes from '../Bytes.std.js';
import { createLogger } from '../logging/log.std.js'; import { createLogger } from '../logging/log.std.js';
import type { AbortableProcess } from '../util/AbortableProcess.std.js';
import type { import type {
ChatKind,
IChatConnection,
IncomingWebSocketRequest, IncomingWebSocketRequest,
IWebSocketResource, IWebSocketResource,
WebSocketResourceOptions, WebSocketResourceOptions,
@@ -97,8 +101,8 @@ export class SocketManager extends EventListener {
jitter: JITTER, jitter: JITTER,
}); });
#authenticated?: AbortableProcess<IWebSocketResource>; #authenticated?: AbortableProcess<IChatConnection<'auth'>>;
#unauthenticated?: AbortableProcess<IWebSocketResource>; #unauthenticated?: AbortableProcess<IChatConnection<'unauth'>>;
#unauthenticatedExpirationTimer?: NodeJS.Timeout; #unauthenticatedExpirationTimer?: NodeJS.Timeout;
#credentials?: WebAPICredentials; #credentials?: WebAPICredentials;
#lazyProxyAgent?: Promise<ProxyAgent>; #lazyProxyAgent?: Promise<ProxyAgent>;
@@ -260,7 +264,7 @@ export class SocketManager extends EventListener {
} }
}; };
let authenticated: IWebSocketResource; let authenticated: IChatConnection<'auth'>;
try { try {
authenticated = await process.getResult(); authenticated = await process.getResult();
@@ -364,7 +368,7 @@ export class SocketManager extends EventListener {
// Either returns currently connecting/active authenticated // Either returns currently connecting/active authenticated
// IWebSocketResource or connects a fresh one. // IWebSocketResource or connects a fresh one.
public async getAuthenticatedResource(): Promise<IWebSocketResource> { public async getAuthenticatedResource(): Promise<IChatConnection<'auth'>> {
if (!this.#authenticated) { if (!this.#authenticated) {
strictAssert(this.#credentials !== undefined, 'Missing credentials'); strictAssert(this.#credentials !== undefined, 'Missing credentials');
await this.authenticate(this.#credentials); await this.authenticate(this.#credentials);
@@ -427,13 +431,18 @@ export class SocketManager extends EventListener {
}).getResult(); }).getResult();
} }
public async getUnauthenticatedLibsignalApi(): Promise<UnauthUsernamesService> {
const resource = await this.#getUnauthenticatedResource();
return resource.libsignalWebsocket;
}
// Fetch-compatible wrapper around underlying unauthenticated/authenticated // Fetch-compatible wrapper around underlying unauthenticated/authenticated
// websocket resources. This wrapper supports only limited number of features // websocket resources. This wrapper supports only limited number of features
// of node-fetch despite being API compatible. // of node-fetch despite being API compatible.
public async fetch(url: string, init: RequestInit): Promise<Response> { public async fetch(url: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
let resource: IWebSocketResource; let resource: IChatConnection<'auth'> | IChatConnection<'unauth'>;
if (this.#isAuthenticated(headers)) { if (this.#isAuthenticated(headers)) {
resource = await this.getAuthenticatedResource(); resource = await this.getAuthenticatedResource();
} else { } else {
@@ -618,7 +627,7 @@ export class SocketManager extends EventListener {
} }
} }
async #getUnauthenticatedResource(): Promise<IWebSocketResource> { async #getUnauthenticatedResource(): Promise<IChatConnection<'unauth'>> {
if (this.#expirationReason) { if (this.#expirationReason) {
throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, { throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, {
code: 0, code: 0,
@@ -642,7 +651,7 @@ export class SocketManager extends EventListener {
window.SignalContext.getResolvedMessagesLocale() window.SignalContext.getResolvedMessagesLocale()
); );
const process: AbortableProcess<IWebSocketResource> = const process: AbortableProcess<IChatConnection<'unauth'>> =
connectUnauthenticatedLibsignal({ connectUnauthenticatedLibsignal({
libsignalNet: this.libsignalNet, libsignalNet: this.libsignalNet,
name: UNAUTHENTICATED_CHANNEL_NAME, name: UNAUTHENTICATED_CHANNEL_NAME,
@@ -652,7 +661,7 @@ export class SocketManager extends EventListener {
this.#unauthenticated = process; this.#unauthenticated = process;
let unauthenticated: IWebSocketResource; let unauthenticated: IChatConnection<'unauth'>;
try { try {
unauthenticated = await this.#unauthenticated.getResult(); unauthenticated = await this.#unauthenticated.getResult();
this.#setUnauthenticatedStatus({ this.#setUnauthenticatedStatus({
@@ -771,8 +780,8 @@ export class SocketManager extends EventListener {
return webSocketResourceConnection; return webSocketResourceConnection;
} }
async #checkResource( async #checkResource<Chat extends ChatKind>(
process?: AbortableProcess<IWebSocketResource> process?: AbortableProcess<IChatConnection<Chat>>
): Promise<void> { ): Promise<void> {
if (!process) { if (!process) {
return; return;
@@ -786,7 +795,7 @@ export class SocketManager extends EventListener {
); );
} }
#dropAuthenticated(process: AbortableProcess<IWebSocketResource>): void { #dropAuthenticated(process: AbortableProcess<IChatConnection<'auth'>>): void {
if (this.#authenticated !== process) { if (this.#authenticated !== process) {
return; return;
} }
@@ -807,7 +816,9 @@ export class SocketManager extends EventListener {
} }
} }
#dropUnauthenticated(process: AbortableProcess<IWebSocketResource>): void { #dropUnauthenticated(
process: AbortableProcess<IChatConnection<'unauth'>>
): void {
if (this.#unauthenticated !== process) { if (this.#unauthenticated !== process) {
return; return;
} }
@@ -822,7 +833,7 @@ export class SocketManager extends EventListener {
} }
async #startUnauthenticatedExpirationTimer( async #startUnauthenticatedExpirationTimer(
expected: IWebSocketResource expected: IChatConnection<'unauth'>
): Promise<void> { ): Promise<void> {
const process = this.#unauthenticated; const process = this.#unauthenticated;
strictAssert( strictAssert(

View File

@@ -53,6 +53,7 @@ import type {
UntaggedPniString, UntaggedPniString,
} from '../types/ServiceId.std.js'; } from '../types/ServiceId.std.js';
import { import {
fromAciObject,
ServiceIdKind, ServiceIdKind,
serviceIdSchema, serviceIdSchema,
aciSchema, aciSchema,
@@ -628,27 +629,29 @@ async function _promiseAjax<Type extends ResponseType, OutputShape>(
return result; return result;
} }
async function _retryAjax<Type extends ResponseType, OutputShape>( async function _retry<R>(
url: string | null, f: () => Promise<R>,
options: PromiseAjaxOptionsType<Type, OutputShape>, provided?: { abortSignal?: AbortSignal } & (
providedLimit?: number, | { limit: number; count: number }
providedCount?: number | { limit?: undefined; count?: undefined }
): Promise<unknown> { )
const count = (providedCount || 0) + 1; ): Promise<R> {
const limit = providedLimit || 3; const count = (provided?.count ?? 0) + 1;
const limit = provided?.limit ?? 3;
const abortSignal = provided?.abortSignal;
try { try {
return await _promiseAjax(url, options); return await f();
} catch (e) { } catch (e) {
if ( if (
e instanceof HTTPError && e instanceof HTTPError &&
e.code === -1 && e.code === -1 &&
count < limit && count < limit &&
!options.abortSignal?.aborted !abortSignal?.aborted
) { ) {
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { setTimeout(() => {
resolve(_retryAjax(url, options, limit, count)); resolve(_retry(f, { abortSignal, limit, count }));
}, 1000); }, 1000);
}); });
} }
@@ -681,7 +684,9 @@ async function _outerAjax<Type extends ResponseType, OutputShape>(
return _promiseAjax(url, options); return _promiseAjax(url, options);
} }
return _retryAjax(url, options); return _retry(() => _promiseAjax(url, options), {
abortSignal: options.abortSignal,
});
} }
function makeHTTPError( function makeHTTPError(
@@ -944,13 +949,7 @@ export type GetAccountForUsernameOptionsType = Readonly<{
hash: Uint8Array; hash: Uint8Array;
}>; }>;
const getAccountForUsernameResultZod = z.object({ export type GetAccountForUsernameResultType = AciString | null;
uuid: aciSchema,
});
export type GetAccountForUsernameResultType = z.infer<
typeof getAccountForUsernameResultZod
>;
const getDevicesResultZod = z.object({ const getDevicesResultZod = z.object({
devices: z.array( devices: z.array(
@@ -2450,19 +2449,12 @@ export async function getTransferArchive({
export async function getAccountForUsername({ export async function getAccountForUsername({
hash, hash,
}: GetAccountForUsernameOptionsType): Promise<GetAccountForUsernameResultType> { }: GetAccountForUsernameOptionsType): Promise<GetAccountForUsernameResultType> {
const hashBase64 = toWebSafeBase64(Bytes.toBase64(hash)); const aci = await _retry(async () => {
return _ajax({ const chat = await socketManager.getUnauthenticatedLibsignalApi();
host: 'chatService', return chat.lookUpUsernameHash({ hash });
call: 'username',
httpType: 'GET',
urlParameters: `/${hashBase64}`,
responseType: 'json',
redactUrl: _createRedactor(hashBase64),
unauthenticated: true,
accessKey: undefined,
groupSendToken: undefined,
zodSchema: getAccountForUsernameResultZod,
}); });
return aci ? fromAciObject(aci) : null;
} }
export async function putProfile( export async function putProfile(

View File

@@ -37,9 +37,11 @@ import type { LibSignalError, Net } from '@signalapp/libsignal-client';
import { ErrorCode } from '@signalapp/libsignal-client'; import { ErrorCode } from '@signalapp/libsignal-client';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import type { import type {
AuthenticatedChatConnection,
ChatServerMessageAck, ChatServerMessageAck,
ChatServiceListener, ChatServiceListener,
ConnectionEventsListener, ConnectionEventsListener,
UnauthenticatedChatConnection,
} from '@signalapp/libsignal-client/dist/net/Chat.js'; } from '@signalapp/libsignal-client/dist/net/Chat.js';
import type { EventHandler } from './EventTarget.std.js'; import type { EventHandler } from './EventTarget.std.js';
import EventTarget 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 ChatKind> = Kind extends 'auth'
? AuthenticatedChatConnection
: UnauthenticatedChatConnection;
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export interface IWebSocketResource extends IResource { export interface IWebSocketResource extends IResource {
sendRequest(options: SendRequestOptions): Promise<Response>; sendRequest(options: SendRequestOptions): Promise<Response>;
@@ -296,8 +304,12 @@ export interface IWebSocketResource extends IResource {
localPort(): number | undefined; localPort(): number | undefined;
} }
type LibsignalWebSocketResourceHolder = { export type IChatConnection<Chat extends ChatKind> = IWebSocketResource & {
resource: LibsignalWebSocketResource | undefined; get libsignalWebsocket(): LibsignalChatConnection<Chat>;
};
type LibsignalWebSocketResourceHolder<Chat extends ChatKind> = {
resource: LibsignalWebSocketResource<Chat> | undefined;
}; };
const UNEXPECTED_DISCONNECT_CODE = 3001; const UNEXPECTED_DISCONNECT_CODE = 3001;
@@ -312,20 +324,20 @@ export function connectUnauthenticatedLibsignal({
name: string; name: string;
userLanguages: ReadonlyArray<string>; userLanguages: ReadonlyArray<string>;
keepalive: KeepAliveOptionsType; keepalive: KeepAliveOptionsType;
}): AbortableProcess<LibsignalWebSocketResource> { }): AbortableProcess<LibsignalWebSocketResource<'unauth'>> {
const logId = `LibsignalWebSocketResource(${name})`; const logId = `LibsignalWebSocketResource(${name})`;
const listener: LibsignalWebSocketResourceHolder & ConnectionEventsListener = const listener: LibsignalWebSocketResourceHolder<'unauth'> &
{ ConnectionEventsListener = {
resource: undefined, resource: undefined,
onConnectionInterrupted(cause: LibSignalError | null): void { onConnectionInterrupted(cause: LibSignalError | null): void {
if (!this.resource) { if (!this.resource) {
logDisconnectedListenerWarn(logId, 'onConnectionInterrupted'); logDisconnectedListenerWarn(logId, 'onConnectionInterrupted');
return; return;
} }
this.resource.onConnectionInterrupted(cause); this.resource.onConnectionInterrupted(cause);
this.resource = undefined; this.resource = undefined;
}, },
}; };
return connectLibsignal( return connectLibsignal(
abortSignal => abortSignal =>
libsignalNet.connectUnauthenticatedChat(listener, { libsignalNet.connectUnauthenticatedChat(listener, {
@@ -356,9 +368,10 @@ export function connectAuthenticatedLibsignal({
receiveStories: boolean; receiveStories: boolean;
userLanguages: ReadonlyArray<string>; userLanguages: ReadonlyArray<string>;
keepalive: KeepAliveOptionsType; keepalive: KeepAliveOptionsType;
}): AbortableProcess<LibsignalWebSocketResource> { }): AbortableProcess<LibsignalWebSocketResource<'auth'>> {
const logId = `LibsignalWebSocketResource(${name})`; const logId = `LibsignalWebSocketResource(${name})`;
const listener: LibsignalWebSocketResourceHolder & ChatServiceListener = { const listener: LibsignalWebSocketResourceHolder<'auth'> &
ChatServiceListener = {
resource: undefined, resource: undefined,
onIncomingMessage( onIncomingMessage(
envelope: Uint8Array, envelope: Uint8Array,
@@ -418,16 +431,14 @@ function logDisconnectedListenerWarn(logId: string, method: string): void {
log.warn(`${logId} received ${method}, but listener already disconnected`); log.warn(`${logId} received ${method}, but listener already disconnected`);
} }
function connectLibsignal( function connectLibsignal<Chat extends ChatKind>(
makeConnection: ( makeConnection: (
abortSignal: AbortSignal abortSignal: AbortSignal
) => Promise< ) => Promise<LibsignalChatConnection<Chat>>,
Net.UnauthenticatedChatConnection | Net.AuthenticatedChatConnection resourceHolder: LibsignalWebSocketResourceHolder<Chat>,
>,
resourceHolder: LibsignalWebSocketResourceHolder,
logId: string, logId: string,
keepalive: KeepAliveOptionsType keepalive: KeepAliveOptionsType
): AbortableProcess<LibsignalWebSocketResource> { ): AbortableProcess<LibsignalWebSocketResource<Chat>> {
const abortController = new AbortController(); const abortController = new AbortController();
const connectAsync = async () => { const connectAsync = async () => {
try { try {
@@ -454,7 +465,7 @@ function connectLibsignal(
throw error; throw error;
} }
}; };
return new AbortableProcess<LibsignalWebSocketResource>( return new AbortableProcess<LibsignalWebSocketResource<Chat>>(
`${logId}.connect`, `${logId}.connect`,
{ {
abort() { abort() {
@@ -470,9 +481,9 @@ function connectLibsignal(
); );
} }
export class LibsignalWebSocketResource export class LibsignalWebSocketResource<Chat extends ChatKind>
extends EventTarget extends EventTarget
implements IWebSocketResource implements IChatConnection<Chat>
{ {
// The reason that the connection was closed, if it was closed. // The reason that the connection was closed, if it was closed.
// //
@@ -487,7 +498,7 @@ export class LibsignalWebSocketResource
#keepalive: KeepAlive; #keepalive: KeepAlive;
constructor( constructor(
private readonly chatService: Net.ChatConnection, private readonly chatService: LibsignalChatConnection<Chat>,
private readonly socketIpVersion: IpVersion, private readonly socketIpVersion: IpVersion,
private readonly localPortNumber: number, private readonly localPortNumber: number,
private readonly logId: string, private readonly logId: string,
@@ -576,6 +587,10 @@ export class LibsignalWebSocketResource
return response; return response;
} }
get libsignalWebsocket(): LibsignalChatConnection<Chat> {
return this.chatService;
}
public async sendRequestGetDebugInfo( public async sendRequestGetDebugInfo(
options: SendRequestOptions options: SendRequestOptions
): Promise<Response> { ): Promise<Response> {

View File

@@ -1,21 +1,20 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 type { UserNotFoundModalStateType } from '../state/ducks/globalModals.preload.js';
import { createLogger } from '../logging/log.std.js'; import { createLogger } from '../logging/log.std.js';
import type { AciString } from '../types/ServiceId.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 { import {
getAccountForUsername, getAccountForUsername,
cdsLookup, cdsLookup,
} from '../textsecure/WebAPI.preload.js'; } 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'); const log = createLogger('lookupConversationWithoutServiceId');
@@ -155,33 +154,17 @@ export async function checkForUsername(
return undefined; return undefined;
} }
try { const accountAci = await getAccountForUsername({
const account = await getAccountForUsername({ hash,
hash, });
});
if (!account.uuid) { if (!accountAci) {
log.error("checkForUsername: Returned account didn't include a uuid"); log.error("checkForUsername: Returned account didn't include a uuid");
return; 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;
} }
return {
aci: accountAci,
username: fixedUsername,
};
} }