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 { 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<IWebSocketResource>;
#unauthenticated?: AbortableProcess<IWebSocketResource>;
#authenticated?: AbortableProcess<IChatConnection<'auth'>>;
#unauthenticated?: AbortableProcess<IChatConnection<'unauth'>>;
#unauthenticatedExpirationTimer?: NodeJS.Timeout;
#credentials?: WebAPICredentials;
#lazyProxyAgent?: Promise<ProxyAgent>;
@@ -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<IWebSocketResource> {
public async getAuthenticatedResource(): Promise<IChatConnection<'auth'>> {
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<UnauthUsernamesService> {
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<Response> {
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<IWebSocketResource> {
async #getUnauthenticatedResource(): Promise<IChatConnection<'unauth'>> {
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<IWebSocketResource> =
const process: AbortableProcess<IChatConnection<'unauth'>> =
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<IWebSocketResource>
async #checkResource<Chat extends ChatKind>(
process?: AbortableProcess<IChatConnection<Chat>>
): Promise<void> {
if (!process) {
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) {
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) {
return;
}
@@ -822,7 +833,7 @@ export class SocketManager extends EventListener {
}
async #startUnauthenticatedExpirationTimer(
expected: IWebSocketResource
expected: IChatConnection<'unauth'>
): Promise<void> {
const process = this.#unauthenticated;
strictAssert(

View File

@@ -53,6 +53,7 @@ import type {
UntaggedPniString,
} from '../types/ServiceId.std.js';
import {
fromAciObject,
ServiceIdKind,
serviceIdSchema,
aciSchema,
@@ -628,27 +629,29 @@ async function _promiseAjax<Type extends ResponseType, OutputShape>(
return result;
}
async function _retryAjax<Type extends ResponseType, OutputShape>(
url: string | null,
options: PromiseAjaxOptionsType<Type, OutputShape>,
providedLimit?: number,
providedCount?: number
): Promise<unknown> {
const count = (providedCount || 0) + 1;
const limit = providedLimit || 3;
async function _retry<R>(
f: () => Promise<R>,
provided?: { abortSignal?: AbortSignal } & (
| { limit: number; count: number }
| { limit?: undefined; count?: undefined }
)
): Promise<R> {
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<Type extends ResponseType, OutputShape>(
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<GetAccountForUsernameResultType> {
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(

View File

@@ -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 ChatKind> = Kind extends 'auth'
? AuthenticatedChatConnection
: UnauthenticatedChatConnection;
// eslint-disable-next-line no-restricted-syntax
export interface IWebSocketResource extends IResource {
sendRequest(options: SendRequestOptions): Promise<Response>;
@@ -296,8 +304,12 @@ export interface IWebSocketResource extends IResource {
localPort(): number | undefined;
}
type LibsignalWebSocketResourceHolder = {
resource: LibsignalWebSocketResource | undefined;
export type IChatConnection<Chat extends ChatKind> = IWebSocketResource & {
get libsignalWebsocket(): LibsignalChatConnection<Chat>;
};
type LibsignalWebSocketResourceHolder<Chat extends ChatKind> = {
resource: LibsignalWebSocketResource<Chat> | undefined;
};
const UNEXPECTED_DISCONNECT_CODE = 3001;
@@ -312,20 +324,20 @@ export function connectUnauthenticatedLibsignal({
name: string;
userLanguages: ReadonlyArray<string>;
keepalive: KeepAliveOptionsType;
}): AbortableProcess<LibsignalWebSocketResource> {
}): AbortableProcess<LibsignalWebSocketResource<'unauth'>> {
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<string>;
keepalive: KeepAliveOptionsType;
}): AbortableProcess<LibsignalWebSocketResource> {
}): AbortableProcess<LibsignalWebSocketResource<'auth'>> {
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<Chat extends ChatKind>(
makeConnection: (
abortSignal: AbortSignal
) => Promise<
Net.UnauthenticatedChatConnection | Net.AuthenticatedChatConnection
>,
resourceHolder: LibsignalWebSocketResourceHolder,
) => Promise<LibsignalChatConnection<Chat>>,
resourceHolder: LibsignalWebSocketResourceHolder<Chat>,
logId: string,
keepalive: KeepAliveOptionsType
): AbortableProcess<LibsignalWebSocketResource> {
): AbortableProcess<LibsignalWebSocketResource<Chat>> {
const abortController = new AbortController();
const connectAsync = async () => {
try {
@@ -454,7 +465,7 @@ function connectLibsignal(
throw error;
}
};
return new AbortableProcess<LibsignalWebSocketResource>(
return new AbortableProcess<LibsignalWebSocketResource<Chat>>(
`${logId}.connect`,
{
abort() {
@@ -470,9 +481,9 @@ function connectLibsignal(
);
}
export class LibsignalWebSocketResource
export class LibsignalWebSocketResource<Chat extends ChatKind>
extends EventTarget
implements IWebSocketResource
implements IChatConnection<Chat>
{
// 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<Chat>,
private readonly socketIpVersion: IpVersion,
private readonly localPortNumber: number,
private readonly logId: string,
@@ -576,6 +587,10 @@ export class LibsignalWebSocketResource
return response;
}
get libsignalWebsocket(): LibsignalChatConnection<Chat> {
return this.chatService;
}
public async sendRequestGetDebugInfo(
options: SendRequestOptions
): Promise<Response> {

View File

@@ -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,
};
}