signal-desktop/ts/services/username.ts

323 lines
9.1 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2021 Signal Messenger, LLC
2022-10-18 17:12:02 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import {
usernames,
LibSignalErrorBase,
ErrorCode,
} from '@signalapp/libsignal-client';
2023-02-08 17:14:59 +00:00
2022-10-18 17:12:02 +00:00
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
2023-02-08 17:14:59 +00:00
import { strictAssert } from '../util/assert';
2022-10-18 17:12:02 +00:00
import { sleep } from '../util/sleep';
2023-02-08 17:14:59 +00:00
import { getMinNickname, getMaxNickname } from '../util/Username';
2023-08-09 00:53:06 +00:00
import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
2022-10-18 17:12:02 +00:00
import type { UsernameReservationType } from '../types/Username';
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
2022-10-18 17:12:02 +00:00
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import MessageSender from '../textsecure/SendMessage';
import { HTTPError } from '../textsecure/Errors';
import { findRetryAfterTimeFromError } from '../jobs/helpers/findRetryAfterTimeFromError';
2023-07-20 03:14:08 +00:00
import * as Bytes from '../Bytes';
import { storageServiceUploadJob } from './storage';
2022-10-18 17:12:02 +00:00
export type WriteUsernameOptionsType = Readonly<
| {
reservation: UsernameReservationType;
}
| {
username: undefined;
previousUsername: string | undefined;
reservation?: undefined;
}
>;
export type ReserveUsernameOptionsType = Readonly<{
nickname: string;
previousUsername: string | undefined;
abortSignal?: AbortSignal;
}>;
export type ReserveUsernameResultType = Readonly<
| {
ok: true;
reservation: UsernameReservationType;
error?: void;
}
| {
ok: false;
reservation?: void;
error: ReserveUsernameError;
}
>;
export async function reserveUsername(
options: ReserveUsernameOptionsType
): Promise<ReserveUsernameResultType> {
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const { nickname, previousUsername, abortSignal } = options;
const me = window.ConversationController.getOurConversationOrThrow();
if (me.get('username') !== previousUsername) {
throw new Error('reserveUsername: Username has changed on another device');
}
try {
2023-02-08 17:14:59 +00:00
const candidates = usernames.generateCandidates(
2022-10-18 17:12:02 +00:00
nickname,
2023-02-08 17:14:59 +00:00
getMinNickname(),
getMaxNickname()
);
const hashes = candidates.map(username => usernames.hash(username));
const { usernameHash } = await server.reserveUsername({
hashes,
2022-10-18 17:12:02 +00:00
abortSignal,
});
2023-02-08 17:14:59 +00:00
const index = hashes.findIndex(hash => hash.equals(usernameHash));
if (index === -1) {
log.warn('reserveUsername: failed to find username hash in the response');
return { ok: false, error: ReserveUsernameError.Unprocessable };
}
const username = candidates[index];
2022-10-18 17:12:02 +00:00
return {
ok: true,
2023-02-08 17:14:59 +00:00
reservation: { previousUsername, username, hash: usernameHash },
2022-10-18 17:12:02 +00:00
};
} catch (error) {
if (error instanceof HTTPError) {
if (error.code === 422) {
return { ok: false, error: ReserveUsernameError.Unprocessable };
}
if (error.code === 409) {
return { ok: false, error: ReserveUsernameError.Conflict };
}
if (error.code === 413 || error.code === 429) {
2022-10-18 17:12:02 +00:00
const time = findRetryAfterTimeFromError(error);
log.warn(`reserveUsername: got ${error.code}, waiting ${time}ms`);
2022-10-18 17:12:02 +00:00
await sleep(time, abortSignal);
return reserveUsername(options);
}
}
if (error instanceof LibSignalErrorBase) {
if (
error.code === ErrorCode.CannotBeEmpty ||
error.code === ErrorCode.NicknameTooShort
) {
return {
ok: false,
error: ReserveUsernameError.NotEnoughCharacters,
};
}
if (error.code === ErrorCode.NicknameTooLong) {
return {
ok: false,
error: ReserveUsernameError.TooManyCharacters,
};
}
if (error.code === ErrorCode.CannotStartWithDigit) {
return {
ok: false,
error: ReserveUsernameError.CheckStartingCharacter,
};
}
if (error.code === ErrorCode.BadNicknameCharacter) {
return {
ok: false,
error: ReserveUsernameError.CheckCharacters,
};
}
}
2022-10-18 17:12:02 +00:00
throw error;
}
}
async function updateUsernameAndSyncProfile(
username: string | undefined
): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow();
// Update backbone, update DB, then tell linked devices about profile update
await me.updateUsername(username);
2022-10-18 17:12:02 +00:00
try {
await singleProtoJobQueue.add(
MessageSender.getFetchLocalProfileSyncMessage()
);
} catch (error) {
log.error(
'updateUsernameAndSyncProfile: Failed to queue sync message',
Errors.toLogFormat(error)
);
}
}
export async function confirmUsername(
reservation: UsernameReservationType,
abortSignal?: AbortSignal
): Promise<ConfirmUsernameResult> {
2022-10-18 17:12:02 +00:00
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
2023-02-08 17:14:59 +00:00
const { previousUsername, username, hash } = reservation;
2022-10-18 17:12:02 +00:00
const me = window.ConversationController.getOurConversationOrThrow();
if (me.get('username') !== previousUsername) {
throw new Error('Username has changed on another device');
}
2023-02-08 17:14:59 +00:00
const proof = usernames.generateProof(username);
strictAssert(usernames.hash(username).equals(hash), 'username hash mismatch');
2022-10-18 17:12:02 +00:00
try {
2023-07-20 23:19:32 +00:00
const { entropy, encryptedUsername } =
usernames.createUsernameLink(username);
await window.storage.remove('usernameLink');
await window.storage.remove('usernameCorrupted');
await window.storage.remove('usernameLinkCorrupted');
2023-07-20 23:19:32 +00:00
const { usernameLinkHandle: serverIdString } = await server.confirmUsername(
{
hash,
proof,
encryptedUsername,
abortSignal,
}
);
await window.storage.put('usernameLink', {
entropy,
serverId: uuidToBytes(serverIdString),
2022-10-18 17:12:02 +00:00
});
await updateUsernameAndSyncProfile(username);
} catch (error) {
if (error instanceof HTTPError) {
if (error.code === 413 || error.code === 429) {
2022-10-18 17:12:02 +00:00
const time = findRetryAfterTimeFromError(error);
log.warn(`confirmUsername: got ${error.code}, waiting ${time}ms`);
2022-10-18 17:12:02 +00:00
await sleep(time, abortSignal);
return confirmUsername(reservation, abortSignal);
}
if (error.code === 409 || error.code === 410) {
return ConfirmUsernameResult.ConflictOrGone;
}
2022-10-18 17:12:02 +00:00
}
throw error;
}
return ConfirmUsernameResult.Ok;
2022-10-18 17:12:02 +00:00
}
export async function deleteUsername(
previousUsername: string | undefined,
2022-10-18 17:12:02 +00:00
abortSignal?: AbortSignal
): Promise<void> {
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const me = window.ConversationController.getOurConversationOrThrow();
if (me.get('username') !== previousUsername) {
throw new Error('Username has changed on another device');
}
2023-07-20 03:14:08 +00:00
await window.storage.remove('usernameLink');
await window.storage.remove('usernameCorrupted');
2022-10-18 17:12:02 +00:00
await server.deleteUsername(abortSignal);
await updateUsernameAndSyncProfile(undefined);
}
2023-07-20 03:14:08 +00:00
export async function resetLink(username: string): Promise<void> {
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const me = window.ConversationController.getOurConversationOrThrow();
if (me.get('username') !== username) {
throw new Error('Username has changed on another device');
}
2023-07-20 23:19:32 +00:00
const { entropy, encryptedUsername } = usernames.createUsernameLink(username);
2023-07-20 03:14:08 +00:00
await window.storage.remove('usernameLink');
await window.storage.remove('usernameLinkCorrupted');
2023-07-20 03:14:08 +00:00
const { usernameLinkHandle: serverIdString } =
2023-07-20 23:19:32 +00:00
await server.replaceUsernameLink({ encryptedUsername });
2023-07-20 03:14:08 +00:00
await window.storage.put('usernameLink', {
2023-07-20 23:19:32 +00:00
entropy,
2023-07-20 03:14:08 +00:00
serverId: uuidToBytes(serverIdString),
});
me.captureChange('usernameLink');
storageServiceUploadJob();
}
const USERNAME_LINK_ENTROPY_SIZE = 32;
export async function resolveUsernameByLinkBase64(
base64: string
): Promise<string | undefined> {
const content = Bytes.fromBase64(base64);
const entropy = content.slice(0, USERNAME_LINK_ENTROPY_SIZE);
const serverId = content.slice(USERNAME_LINK_ENTROPY_SIZE);
return resolveUsernameByLink({ entropy, serverId });
}
export type ResolveUsernameByLinkOptionsType = Readonly<{
entropy: Uint8Array;
serverId: Uint8Array;
}>;
export async function resolveUsernameByLink({
entropy,
serverId: serverIdBytes,
}: ResolveUsernameByLinkOptionsType): Promise<string | undefined> {
2023-07-20 03:14:08 +00:00
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const serverId = bytesToUuid(serverIdBytes);
strictAssert(serverId, 'Failed to re-encode server id as uuid');
strictAssert(window.textsecure.server, 'WebAPI must be available');
try {
const { usernameLinkEncryptedValue } = await server.resolveUsernameLink(
serverId
);
2023-07-20 03:14:08 +00:00
return usernames.decryptUsernameLink({
entropy: Buffer.from(entropy),
encryptedUsername: Buffer.from(usernameLinkEncryptedValue),
});
} catch (error) {
if (error instanceof HTTPError && error.code === 404) {
return undefined;
}
throw error;
}
2023-07-20 03:14:08 +00:00
}