Username and username link integrity check

This commit is contained in:
Fedor Indutny 2023-11-03 23:05:11 +01:00 committed by GitHub
parent 1be90fff3d
commit 3664063d71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 636 additions and 35 deletions

View file

@ -315,6 +315,22 @@
"messageformat": "Chats",
"description": "Shown as a header for non-pinned conversations in the left pane"
},
"icu:LeftPane--corrupted-username--text": {
"messageformat": "Something went wrong with your username, its no longer assigned to your account. You can try and set it again or choose a new one.",
"description": "Text of corrupted username banner in the left pane"
},
"icu:LeftPane--corrupted-username--action-text": {
"messageformat": "Fix now",
"description": "Text of the button in the corrupted username banner in the left pane"
},
"icu:LeftPane--corrupted-username-link--text": {
"messageformat": "Something went wrong with your QR code and username link, its no longer valid. Create a new link to share with others.",
"description": "Text of corrupted username link banner in the left pane"
},
"icu:LeftPane--corrupted-username-link--action-text": {
"messageformat": "Fix now",
"description": "Text of the button in the corrupted username link banner in the left pane"
},
"icu:NavTabsToggle__showTabs": {
"messageformat": "Show Tabs",
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
@ -5419,10 +5435,30 @@
"messageformat": "Username",
"description": "Default text for username field"
},
"icu:ProfileEditor--username--corrupted--body": {
"messageformat": "Something went wrong with your username, its no longer assigned to your account.",
"description": "Text of confirmation modal when the username gets corrupted"
},
"icu:ProfileEditor--username--corrupted--delete-button": {
"messageformat": "Delete username",
"description": "Button text for deletion of the username in case of corruption"
},
"icu:ProfileEditor--username--corrupted--create-button": {
"messageformat": "Create username",
"description": "Button text for creation of a new username in case of corruption"
},
"icu:ProfileEditor__username-link": {
"messageformat": "QR code or link",
"description": "Label of a profile editor row that navigates to username link and qr code modal"
},
"icu:ProfileEditor__username__error-icon": {
"messageformat": "Username needs reset",
"description": "Accessibility title of an icon in profile editor"
},
"icu:ProfileEditor__username-link__error-icon": {
"messageformat": "Username link needs reset",
"description": "Accessibility title of an icon in profile editor"
},
"icu:ProfileEditor__username-link__tooltip__title": {
"messageformat": "Share your username",
"description": "Title of tooltip displayed under 'QR code or link' button for getting username link"
@ -6711,6 +6747,14 @@
"messageformat": "If you reset your QR code, your existing QR code and link will no longer work.",
"description": "Text of confirmation modal when resetting the username link"
},
"icu:UsernameLinkModalBody__resetting-link": {
"messageformat": "Resetting link...",
"description": "Text shown when resetting the username link"
},
"icu:UsernameLinkModalBody__error__text": {
"messageformat": "QR code and link not set. Check your network connection and try again.",
"description": "Text of the confirmation dialog shown on username link error"
},
"icu:UsernameOnboardingModalBody__title": {
"messageformat": "Set up your Signal username",
"description": "Title of username onboarding modal"

View file

@ -0,0 +1,45 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.LeftPaneBanner {
margin-block: 4px 8px;
margin-inline: 10px;
padding-block: 12px 16px;
padding-inline-start: 12px;
padding-inline-end: 7px;
border-radius: 8px;
@include light-theme {
background-color: $color-gray-15;
color: $color-gray-75;
}
@include dark-theme {
background-color: $color-gray-60;
color: $color-gray-15;
}
@include font-body-2;
&__content {
margin-bottom: 8px;
}
&__footer {
display: flex;
justify-content: end;
margin-inline-end: 9px;
&__action-button {
@include button-reset;
@include button-focus-outline;
@include font-body-1-bold;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
}
}

View file

@ -187,8 +187,8 @@
padding-inline: 16px;
gap: 8px;
.module-Button {
margin-inline-start: 8px;
.module-Button:not(:first-child) {
margin-inline-start: 4px;
}
&--one-button-per-line {

View file

@ -178,6 +178,28 @@
}
}
&__error-icon {
-webkit-mask-size: 100%;
display: block;
height: 24px;
width: 24px;
margin: 4px;
@include light-theme {
@include color-svg(
'../images/icons/v3/error/error-circle.svg',
$color-accent-red
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/error/error-circle.svg',
$color-accent-red
);
}
}
&__username-link {
&__tooltip {
padding: 12px;
@ -234,6 +256,14 @@
}
}
}
&__reset-username-modal__ModalHost__width-container {
max-width: 438px;
.module-Modal__button-footer {
justify-content: space-between;
}
}
}
.ProfileEditor__Title {

View file

@ -64,6 +64,18 @@
height: var(--size);
@include color-svg('../images/signal-qr-logo.svg', var(--fg-color));
}
&__error-icon {
-webkit-mask-size: 100%;
display: block;
height: 28px;
width: 28px;
@include color-svg(
'../images/icons/v3/error/error-circle.svg',
$color-gray-25
);
}
}
&__username {
@ -209,11 +221,31 @@
);
}
}
&:disabled:after {
@include light-theme() {
background-color: $color-gray-45;
}
@include dark-theme() {
background-color: $color-gray-25;
}
}
}
&__text {
word-break: break-all;
user-select: text;
&--resetting {
@include light-theme() {
color: $color-gray-45;
}
@include dark-theme() {
color: $color-gray-25;
}
}
}
}

View file

@ -92,6 +92,7 @@
@import './components/InstallScreenQrCodeNotScannedStep.scss';
@import './components/InstallScreenSignalLogo.scss';
@import './components/InstallScreenUpdateDialog.scss';
@import './components/LeftPaneBanner.scss';
@import './components/LeftPaneDialog.scss';
@import './components/LeftPaneSearchInput.scss';
@import './components/Lightbox.scss';

View file

@ -51,6 +51,7 @@ import { senderCertificateService } from './services/senderCertificate';
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
import * as KeyboardLayout from './services/keyboardLayout';
import * as StorageService from './services/storage';
import { usernameIntegrity } from './services/usernameIntegrity';
import { RoutineProfileRefresher } from './routineProfileRefresh';
import { isOlderThan, toDayMillis } from './util/timestamp';
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
@ -2051,6 +2052,8 @@ export async function startApp(): Promise<void> {
void routineProfileRefresher.start();
}
drop(usernameIntegrity.start());
}
let initialStartupCount = 0;

View file

@ -158,6 +158,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
hasRelinkDialog: false,
hasUpdateDialog: false,
unsupportedOSDialogType: undefined,
usernameCorrupted: false,
usernameLinkCorrupted: false,
isUpdateDownloaded,
navTabsCollapsed: false,
@ -269,6 +271,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
'toggleConversationInChooseMembers'
),
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
toggleProfileEditor: action('toggleProfileEditor'),
updateSearchTerm: action('updateSearchTerm'),
...overrideProps,
@ -302,6 +305,42 @@ export function InboxNoConversations(): JSX.Element {
);
}
export function InboxUsernameCorrupted(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: [],
archivedConversations: [],
isAboutToSearch: false,
},
usernameCorrupted: true,
})}
/>
);
}
export function InboxUsernameLinkCorrupted(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: [],
archivedConversations: [],
isAboutToSearch: false,
},
usernameLinkCorrupted: true,
})}
/>
);
}
export function InboxOnlyPinnedConversations(): JSX.Element {
return (
<LeftPaneInContainer

View file

@ -35,6 +35,7 @@ import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/U
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import type { PropsType as DialogExpiredBuildPropsType } from './DialogExpiredBuild';
import { LeftPaneBanner } from './LeftPaneBanner';
import type {
DeleteAvatarFromDiskActionType,
@ -69,6 +70,8 @@ export type PropsType = {
hasUpdateDialog: boolean;
isUpdateDownloaded: boolean;
unsupportedOSDialogType: 'error' | 'warning' | undefined;
usernameCorrupted: boolean;
usernameLinkCorrupted: boolean;
// These help prevent invalid states. For example, we don't need the list of pinned
// conversations if we're trying to start a new conversation. Ideally these would be
@ -135,6 +138,7 @@ export type PropsType = {
toggleComposeEditingAvatar: () => unknown;
toggleConversationInChooseMembers: (conversationId: string) => void;
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
toggleProfileEditor: () => void;
updateSearchTerm: (_: string) => void;
// Render Props
@ -201,6 +205,7 @@ export function LeftPane({
selectedConversationId,
targetedMessageId,
toggleNavTabsCollapse,
toggleProfileEditor,
setChallengeStatus,
setComposeGroupAvatar,
setComposeGroupExpireTimer,
@ -219,6 +224,8 @@ export function LeftPane({
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
unsupportedOSDialogType,
usernameCorrupted,
usernameLinkCorrupted,
updateSearchTerm,
}: PropsType): JSX.Element {
const previousModeSpecificProps = usePrevious(
@ -544,6 +551,31 @@ export function LeftPane({
}
}
let maybeBanner: JSX.Element | undefined;
if (usernameCorrupted) {
maybeBanner = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username--action-text')}
onClick={toggleProfileEditor}
>
{i18n('icu:LeftPane--corrupted-username--text')}
</LeftPaneBanner>
);
} else if (usernameLinkCorrupted) {
maybeBanner = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')}
onClick={toggleProfileEditor}
>
{i18n('icu:LeftPane--corrupted-username-link--text')}
</LeftPaneBanner>
);
}
if (maybeBanner) {
dialogs.push({ key: 'banner', dialog: maybeBanner });
}
return (
<NavSidebar
title="Chats"

View file

@ -0,0 +1,25 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { LeftPaneBanner, type PropsType } from './LeftPaneBanner';
export default {
title: 'Components/LeftPaneBanner',
component: LeftPaneBanner,
argTypes: {
actionText: { control: { type: 'string' } },
children: { control: { type: 'string' } },
},
args: {
actionText: 'Fix now',
children: 'Recoverable issue detected',
onClick: action('onClick'),
},
} satisfies Meta<PropsType>;
export function Defaults(args: PropsType): JSX.Element {
return <LeftPaneBanner {...args} />;
}

View file

@ -0,0 +1,48 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useCallback } from 'react';
const BASE_CLASS_NAME = 'LeftPaneBanner';
export type PropsType = Readonly<{
children?: ReactNode;
actionText: string;
onClick: () => void;
}>;
export function LeftPaneBanner({
children,
actionText,
onClick,
}: PropsType): JSX.Element {
const onClickWrap = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
},
[onClick]
);
return (
<div className={BASE_CLASS_NAME}>
<div className={`${BASE_CLASS_NAME}__content`}>{children}</div>
<div className={`${BASE_CLASS_NAME}__footer`}>
<button
title={actionText}
aria-label={actionText}
className={`${BASE_CLASS_NAME}__footer__action-button`}
onClick={onClickWrap}
tabIndex={0}
type="button"
>
{actionText}
</button>
</div>
</div>
);
}

View file

@ -37,10 +37,16 @@ export default {
Deleting: UsernameEditState.Deleting,
},
},
usernameCorrupted: {
control: 'boolean',
},
usernameLinkState: {
control: { type: 'select' },
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
},
usernameLinkCorrupted: {
control: 'boolean',
},
},
args: {
aboutEmoji: '',

View file

@ -82,10 +82,12 @@ export type PropsDataType = {
phoneNumber?: string;
userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string;
usernameCorrupted: boolean;
usernameEditState: UsernameEditState;
usernameLinkState: UsernameLinkState;
usernameLinkColor?: number;
usernameLink?: string;
usernameLinkCorrupted: boolean;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type PropsActionType = {
@ -169,10 +171,12 @@ export function ProfileEditor({
skinTone,
userAvatarData,
username,
usernameCorrupted,
usernameEditState,
usernameLinkState,
usernameLinkColor,
usernameLink,
usernameLinkCorrupted,
}: PropsType): JSX.Element {
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
@ -208,6 +212,7 @@ export function ProfileEditor({
familyName,
firstName,
});
const [isResettingUsername, setIsResettingUsername] = useState(false);
// Reset username edit state when leaving
useEffect(() => {
@ -530,6 +535,7 @@ export function ProfileEditor({
link={usernameLink}
username={username ?? ''}
colorId={usernameLinkColor}
usernameLinkCorrupted={usernameLinkCorrupted}
usernameLinkState={usernameLinkState}
setUsernameLinkColor={setUsernameLinkColor}
resetUsernameLink={resetUsernameLink}
@ -542,6 +548,7 @@ export function ProfileEditor({
let maybeUsernameRows: JSX.Element | undefined;
if (isUsernameFlagEnabled) {
let actions: JSX.Element | undefined;
let alwaysShowActions = false;
if (usernameEditState === UsernameEditState.Deleting) {
actions = (
@ -579,7 +586,15 @@ export function ProfileEditor({
},
];
if (username) {
if (usernameCorrupted) {
actions = (
<i
className="ProfileEditor__error-icon"
title={i18n('icu:ProfileEditor__username__error-icon')}
/>
);
alwaysShowActions = true;
} else if (username) {
actions = (
<ContextMenu
i18n={i18n}
@ -593,7 +608,18 @@ export function ProfileEditor({
}
let maybeUsernameLinkRow: JSX.Element | undefined;
if (username) {
if (username && !usernameCorrupted) {
let linkActions: JSX.Element | undefined;
if (usernameLinkCorrupted) {
linkActions = (
<i
className="ProfileEditor__error-icon"
title={i18n('icu:ProfileEditor__username-link__error-icon')}
/>
);
}
maybeUsernameLinkRow = (
<PanelRow
className="ProfileEditor__row"
@ -604,6 +630,8 @@ export function ProfileEditor({
onClick={() => {
setEditState(EditState.UsernameLink);
}}
alwaysShowActions
actions={linkActions}
/>
);
@ -647,8 +675,16 @@ export function ProfileEditor({
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('icu:ProfileEditor--username')}
label={
(!usernameCorrupted && username) ||
i18n('icu:ProfileEditor--username')
}
onClick={() => {
if (usernameCorrupted) {
setIsResettingUsername(true);
return;
}
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
@ -656,6 +692,7 @@ export function ProfileEditor({
setEditState(EditState.UsernameOnboarding);
}
}}
alwaysShowActions={alwaysShowActions}
actions={actions}
/>
{maybeUsernameLinkRow}
@ -771,6 +808,36 @@ export function ProfileEditor({
onClose={() => setConfirmDiscardAction(undefined)}
/>
)}
{isResettingUsername && (
<ConfirmationDialog
dialogName="ProfileEditor.confirmResetUsername"
moduleClassName="ProfileEditor__reset-username-modal"
i18n={i18n}
onClose={() => setIsResettingUsername(false)}
actions={[
{
text: i18n(
'icu:ProfileEditor--username--corrupted--delete-button'
),
action: () => deleteUsername(),
},
{
text: i18n(
'icu:ProfileEditor--username--corrupted--create-button'
),
style: 'affirmative',
action: () => {
openUsernameReservationModal();
setEditState(EditState.Username);
},
},
]}
>
{i18n('icu:ProfileEditor--username--corrupted--body')}
</ConfirmationDialog>
)}
<div className="ProfileEditor">{content}</div>
</>
);

View file

@ -28,9 +28,16 @@ export default {
username: {
control: { type: 'text' },
},
usernameLinkCorrupted: {
control: 'boolean',
},
usernameLinkState: {
control: { type: 'select' },
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
options: [
UsernameLinkState.Ready,
UsernameLinkState.Updating,
UsernameLinkState.Error,
],
},
colorId: {
control: { type: 'select' },

View file

@ -26,6 +26,7 @@ export type PropsType = Readonly<{
link?: string;
username: string;
colorId?: number;
usernameLinkCorrupted: boolean;
usernameLinkState: UsernameLinkState;
setUsernameLinkColor: (colorId: number) => void;
@ -486,6 +487,7 @@ export function UsernameLinkModalBody({
i18n,
link,
username,
usernameLinkCorrupted,
usernameLinkState,
colorId: initialColorId = ColorEnum.UNKNOWN,
@ -499,6 +501,7 @@ export function UsernameLinkModalBody({
const [pngData, setPngData] = useState<Uint8Array | undefined>();
const [showColors, setShowColors] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [showError, setShowError] = useState(false);
const [colorId, setColorId] = useState(initialColorId);
const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
@ -621,13 +624,36 @@ export function UsernameLinkModalBody({
resetUsernameLink();
}, [resetUsernameLink]);
useEffect(() => {
if (!usernameLinkCorrupted) {
return;
}
resetUsernameLink();
}, [usernameLinkCorrupted, resetUsernameLink]);
useEffect(() => {
if (usernameLinkState !== UsernameLinkState.Error) {
return;
}
setShowError(true);
}, [usernameLinkState]);
const onClearError = useCallback(() => {
setShowError(false);
}, []);
const isResettingLink =
usernameLinkCorrupted || usernameLinkState !== UsernameLinkState.Ready;
const info = (
<>
<div className={classnames(`${CLASS}__actions`)}>
<button
className={`${CLASS}__actions__save`}
type="button"
disabled={!link}
disabled={!link || isResettingLink}
onClick={onSave}
>
<i />
@ -648,11 +674,19 @@ export function UsernameLinkModalBody({
<button
className={classnames(`${CLASS}__link__icon`)}
type="button"
disabled={!link}
disabled={!link || isResettingLink}
onClick={onCopyLink}
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
/>
<div className={classnames(`${CLASS}__link__text`)}>{link}</div>
<div
className={classnames(`${CLASS}__link__text`, {
[`${CLASS}__link__text--resetting`]: isResettingLink,
})}
>
{isResettingLink
? i18n('icu:UsernameLinkModalBody__resetting-link')
: link}
</div>
</div>
<div className={classnames(`${CLASS}__help`)}>
@ -677,6 +711,29 @@ export function UsernameLinkModalBody({
</>
);
let linkImage: JSX.Element | undefined;
if (usernameLinkState === UsernameLinkState.Ready && link) {
linkImage = (
<>
<Blotches
className={`${CLASS}__card__qr__blotches`}
link={link}
color={fgColor}
/>
<div className={`${CLASS}__card__qr__logo`} />
</>
);
} else if (usernameLinkState === UsernameLinkState.Error) {
linkImage = <i className={`${CLASS}__card__qr__error-icon`} />;
} else {
linkImage = (
<Spinner
moduleClassName={`${CLASS}__card__qr__spinner`}
svgSize="small"
/>
);
}
return (
<div className={`${CLASS}__container`}>
<div className={CLASS}>
@ -686,23 +743,7 @@ export function UsernameLinkModalBody({
})}
ref={onCardRef}
>
<div className={`${CLASS}__card__qr`}>
{usernameLinkState === UsernameLinkState.Ready && link ? (
<>
<Blotches
className={`${CLASS}__card__qr__blotches`}
link={link}
color={fgColor}
/>
<div className={`${CLASS}__card__qr__logo`} />
</>
) : (
<Spinner
moduleClassName={`${CLASS}__card__qr__spinner`}
svgSize="small"
/>
)}
</div>
<div className={`${CLASS}__card__qr`}>{linkImage}</div>
<div className={`${CLASS}__card__username`}>
{!showColors && (
<button
@ -733,6 +774,18 @@ export function UsernameLinkModalBody({
</ConfirmationDialog>
)}
{showError && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__error"
onClose={onClearError}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:ok')}
>
{i18n('icu:UsernameLinkModalBody__error__text')}
</ConfirmationDialog>
)}
{showColors ? (
<UsernameLinkColors
i18n={i18n}

View file

@ -1469,6 +1469,17 @@ export async function mergeAccountRecord(
}
if (usernameLink?.entropy?.length && usernameLink?.serverId?.length) {
const oldLink = window.storage.get('usernameLink');
if (
window.storage.get('usernameLinkCorrupted') &&
(!oldLink ||
!Bytes.areEqual(usernameLink.entropy, oldLink.entropy) ||
!Bytes.areEqual(usernameLink.serverId, oldLink.serverId))
) {
details.push('clearing username link corruption');
await window.storage.remove('usernameLinkCorrupted');
}
await Promise.all([
usernameLink.color &&
window.storage.put('usernameLinkColor', usernameLink.color),
@ -1500,6 +1511,14 @@ export async function mergeAccountRecord(
const oldStorageID = conversation.get('storageID');
const oldStorageVersion = conversation.get('storageVersion');
if (
window.storage.get('usernameCorrupted') &&
username !== conversation.get('username')
) {
details.push('clearing username corruption');
await window.storage.remove('usernameCorrupted');
}
conversation.set({
isArchived: Boolean(noteToSelfArchived),
markedUnread: Boolean(noteToSelfMarkedUnread),

View file

@ -186,6 +186,8 @@ export async function confirmUsername(
usernames.createUsernameLink(username);
await window.storage.remove('usernameLink');
await window.storage.remove('usernameCorrupted');
await window.storage.remove('usernameLinkCorrupted');
const { usernameLinkHandle: serverIdString } = await server.confirmUsername(
{
@ -223,7 +225,7 @@ export async function confirmUsername(
}
export async function deleteUsername(
previousUsername: string,
previousUsername: string | undefined,
abortSignal?: AbortSignal
): Promise<void> {
const { server } = window.textsecure;
@ -238,6 +240,7 @@ export async function deleteUsername(
}
await window.storage.remove('usernameLink');
await window.storage.remove('usernameCorrupted');
await server.deleteUsername(abortSignal);
await updateUsernameAndSyncProfile(undefined);
}
@ -257,6 +260,7 @@ export async function resetLink(username: string): Promise<void> {
const { entropy, encryptedUsername } = usernames.createUsernameLink(username);
await window.storage.remove('usernameLink');
await window.storage.remove('usernameLinkCorrupted');
const { usernameLinkHandle: serverIdString } =
await server.replaceUsernameLink({ encryptedUsername });
@ -275,15 +279,27 @@ 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> {
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const content = Bytes.fromBase64(base64);
const entropy = content.slice(0, USERNAME_LINK_ENTROPY_SIZE);
const serverIdBytes = content.slice(USERNAME_LINK_ENTROPY_SIZE);
const serverId = bytesToUuid(serverIdBytes);
strictAssert(serverId, 'Failed to re-encode server id as uuid');

View file

@ -0,0 +1,104 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Errors from '../types/errors';
import { DAY } from '../util/durations';
import { drop } from '../util/drop';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
import { checkForUsername } from '../util/lookupConversationWithoutServiceId';
import * as log from '../logging/log';
import { resolveUsernameByLink } from './username';
const CHECK_INTERVAL = DAY;
class UsernameIntegrityService {
private isStarted = false;
private readonly backOff = new BackOff(FIBONACCI_TIMEOUTS);
async start(): Promise<void> {
if (this.isStarted) {
return;
}
this.isStarted = true;
this.scheduleCheck();
}
private scheduleCheck(): void {
const lastCheckTimestamp = window.storage.get(
'usernameLastIntegrityCheck',
0
);
const delay = Math.max(0, lastCheckTimestamp + CHECK_INTERVAL - Date.now());
if (delay === 0) {
log.info('usernameIntegrity: running the check immediately');
drop(this.safeCheck());
} else {
log.info(`usernameIntegrity: running the check in ${delay}ms`);
setTimeout(() => drop(this.safeCheck()), delay);
}
}
private async safeCheck(): Promise<void> {
try {
await this.check();
this.backOff.reset();
await window.storage.put('usernameLastIntegrityCheck', Date.now());
this.scheduleCheck();
} catch (error) {
const delay = this.backOff.getAndIncrement();
log.error(
'usernameIntegrity: check failed with ' +
`error: ${Errors.toLogFormat(error)} retrying in ${delay}ms`
);
setTimeout(() => drop(this.safeCheck()), delay);
}
}
private async check(): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow();
const username = me.get('username');
const aci = me.getAci();
let failed = false;
if (!username) {
log.info('usernameIntegrity: no username');
return;
}
if (!aci) {
log.info('usernameIntegrity: no aci');
return;
}
const result = await checkForUsername(username);
if (result?.aci !== aci) {
log.error('usernameIntegrity: no remote username');
await window.storage.put('usernameCorrupted', true);
failed = true;
// Intentional fall-through
}
const link = window.storage.get('usernameLink');
if (!link) {
log.info('usernameIntegrity: no username link');
return;
}
const linkUsername = await resolveUsernameByLink(link);
if (linkUsername !== username) {
log.error('usernameIntegrity: invalid username link');
await window.storage.put('usernameLinkCorrupted', true);
failed = true;
}
if (!failed) {
log.info('usernameIntegrity: check pass');
}
}
}
export const usernameIntegrity = new UsernameIntegrityService();

View file

@ -18,6 +18,7 @@ import { assertDev } from '../../util/assert';
import type { StateType as RootStateType } from '../reducer';
import type { PromiseAction } from '../util';
import { getMe } from '../selectors/conversations';
import { getUsernameCorrupted } from '../selectors/items';
import {
UsernameEditState,
UsernameLinkState,
@ -248,9 +249,10 @@ export function deleteUsername({
> {
return (dispatch, getState) => {
const me = getMe(getState());
const isUsernameCorrupted = getUsernameCorrupted(getState());
const username = me.username ?? defaultUsername;
if (!username) {
if (!username && !isUsernameCorrupted) {
return;
}
@ -568,7 +570,7 @@ export function reducer(
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
return {
...state,
linkState: UsernameLinkState.Ready,
linkState: UsernameLinkState.Error,
};
}

View file

@ -18,6 +18,7 @@ export enum UsernameEditState {
export enum UsernameLinkState {
Ready = 'Ready',
Updating = 'Updating',
Error = 'Error',
}
//

View file

@ -93,6 +93,16 @@ export const getHasCompletedUsernameLinkOnboarding = createSelector(
Boolean(state.hasCompletedUsernameLinkOnboarding)
);
export const getUsernameCorrupted = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state.usernameCorrupted)
);
export const getUsernameLinkCorrupted = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state.usernameLinkCorrupted)
);
export const getUsernameLinkColor = createSelector(
getItems,
(state: ItemsStateType): number | undefined => state.usernameLinkColor

View file

@ -15,15 +15,17 @@ import {
getUsernameReservationObject,
getUsernameReservationError,
} from '../selectors/username';
import { getUsernameCorrupted } from '../selectors/items';
import { getMe } from '../selectors/conversations';
function mapStateToProps(state: StateType): PropsDataType {
const i18n = getIntl(state);
const { username } = getMe(state);
const usernameCorrupted = getUsernameCorrupted(state);
return {
i18n,
currentUsername: username,
currentUsername: usernameCorrupted ? undefined : username,
minNickname: getMinNickname(),
maxNickname: getMaxNickname(),
state: getUsernameReservationState(state),

View file

@ -40,6 +40,8 @@ import { hasNetworkDialog } from '../selectors/network';
import {
getPreferredLeftPaneWidth,
getUsernamesEnabled,
getUsernameCorrupted,
getUsernameLinkCorrupted,
getNavTabsCollapsed,
} from '../selectors/items';
import {
@ -203,6 +205,8 @@ const getModeSpecificProps = (
const mapStateToProps = (state: StateType) => {
const hasUpdateDialog = isUpdateDialogVisible(state);
const hasUnsupportedOS = isOSUnsupported(state);
const usernameCorrupted = getUsernameCorrupted(state);
const usernameLinkCorrupted = getUsernameLinkCorrupted(state);
let hasExpiredDialog = false;
let unsupportedOSDialogType: 'error' | 'warning' | undefined;
@ -223,6 +227,8 @@ const mapStateToProps = (state: StateType) => {
hasUpdateDialog,
isUpdateDownloaded: isUpdateDownloaded(state),
unsupportedOSDialogType,
usernameCorrupted,
usernameLinkCorrupted,
modeSpecificProps: getModeSpecificProps(state),
navTabsCollapsed: getNavTabsCollapsed(state),

View file

@ -16,8 +16,10 @@ import {
getUsernamesEnabled,
getHasCompletedUsernameOnboarding,
getHasCompletedUsernameLinkOnboarding,
getUsernameCorrupted,
getUsernameLinkColor,
getUsernameLink,
getUsernameLinkCorrupted,
} from '../selectors/items';
import { getMe } from '../selectors/conversations';
import { selectRecentEmojis } from '../selectors/emojis';
@ -59,6 +61,8 @@ function mapStateToProps(
const usernameLinkState = getUsernameLinkState(state);
const usernameLinkColor = getUsernameLinkColor(state);
const usernameLink = getUsernameLink(state);
const usernameCorrupted = getUsernameCorrupted(state);
const usernameLinkCorrupted = getUsernameLinkCorrupted(state);
return {
aboutEmoji,
@ -78,9 +82,11 @@ function mapStateToProps(
phoneNumber,
userAvatarData,
username,
usernameCorrupted,
usernameEditState,
usernameLinkState,
usernameLinkColor,
usernameLinkCorrupted,
usernameLink,
renderEditUsernameModalBody,

View file

@ -163,6 +163,9 @@ export type StorageAccessType = {
subscriberCurrencyCode: string;
displayBadgesOnProfile: boolean;
keepMutedChatsArchived: boolean;
usernameLastIntegrityCheck: number;
usernameCorrupted: boolean;
usernameLinkCorrupted: boolean;
usernameLinkColor: number;
usernameLink: {
entropy: Uint8Array;

View file

@ -132,7 +132,7 @@ export async function lookupConversationWithoutServiceId(
}
}
async function checkForUsername(
export async function checkForUsername(
username: string
): Promise<FoundUsernameType | undefined> {
let hash: Buffer;