diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 729a113ac8..694bb7327a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8586,6 +8586,18 @@ "messageformat": "Other ways to back up", "description": "Heading on the backups settings view for alternative backup methods such as on-device backups." }, + "icu:Preferences__recovery-key-updated__title": { + "messageformat": "Your recovery key has changed", + "description": "Title in modal warning the user that their recovery key (backup key) has been changed" + }, + "icu:Preferences__recovery-key-updated__description": { + "messageformat": "Your recovery key has been updated. Any new backups you make can only be restored using your new recovery key.", + "description": "Text in modal warning the user that their recovery key (backup key) has been changed" + }, + "icu:Preferences__recovery-key-updated__view-key": { + "messageformat": "View new key", + "description": "Button text to view the new updated recovery key" + }, "icu:Preferences--blocked-count": { "messageformat": "{num, plural, one {# contact} other {# contacts}}", "description": "Number of contacts blocked plural" diff --git a/ts/axo/AxoSymbol.dom.tsx b/ts/axo/AxoSymbol.dom.tsx index 50b84e2b45..ad51f6308e 100644 --- a/ts/axo/AxoSymbol.dom.tsx +++ b/ts/axo/AxoSymbol.dom.tsx @@ -75,7 +75,7 @@ export namespace AxoSymbol { */ export type IconName = AxoSymbolIconName; - export type IconSize = 12 | 14 | 16 | 18 | 20 | 24 | 48; + export type IconSize = 12 | 14 | 16 | 18 | 20 | 24 | 36 | 48; type IconSizeConfig = { size: number; fontSize: number }; @@ -86,6 +86,7 @@ export namespace AxoSymbol { 18: { size: 18, fontSize: 16 }, 20: { size: 20, fontSize: 18 }, 24: { size: 24, fontSize: 22 }, + 36: { size: 36, fontSize: 34 }, 48: { size: 48, fontSize: 44 }, }; diff --git a/ts/background.preload.ts b/ts/background.preload.ts index 1bd564005c..b844aebb43 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -286,6 +286,7 @@ import { itemStorage } from './textsecure/Storage.preload.js'; import { initMessageCleanup } from './services/messageStateCleanup.dom.js'; import { MessageCache } from './services/MessageCache.preload.js'; import { saveAndNotify } from './messages/saveAndNotify.preload.js'; +import { getBackupKeyHash } from './services/backups/crypto.preload.js'; const { isNumber, throttle } = lodash; @@ -1025,6 +1026,20 @@ export async function startApp(): Promise { await itemStorage.remove('callQualitySurveyCooldownDisabled'); await itemStorage.remove('localDeleteWarningShown'); } + + if ( + itemStorage.get('backupKeyViewed') === true && + itemStorage.get('backupKeyViewedHash') == null + ) { + const backupKey = itemStorage.get('accountEntropyPool'); + if (backupKey) { + await itemStorage.put( + 'backupKeyViewedHash', + getBackupKeyHash(backupKey) + ); + } + await itemStorage.remove('backupKeyViewed'); + } } setAppLoadingScreenMessage(i18n('icu:optimizingApplication'), i18n); diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index ab3e07995c..14098767db 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -389,8 +389,9 @@ export default { component: Preferences, args: { i18n, - accountEntropyPool: + backupKey: 'uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t', + backupKeyHash: 'backupkeyhash', autoDownloadAttachment: { photos: true, videos: false, @@ -419,7 +420,6 @@ export default { availableMicrophones, availableSpeakers, backupFreeMediaDays: 45, - backupKeyViewed: false, backupLocalBackupsEnabled: false, backupSubscriptionStatus: { status: 'not-found' }, backupTier: null, @@ -492,6 +492,7 @@ export default { }, preferredSystemLocales: ['en'], preferredWidthFromStorage: 300, + previouslyViewedBackupKeyHash: 'hash', resolvedLocale: 'en', selectedCamera: 'dfbe6effe70b0611ba0fdc2a9ea3f39f6cb110e6687948f7e5f016c111b7329c', @@ -557,7 +558,7 @@ export default { onAutoDownloadAttachmentChange: action('onAutoDownloadAttachmentChange'), onAutoDownloadUpdateChange: action('onAutoDownloadUpdateChange'), onAutoLaunchChange: action('onAutoLaunchChange'), - onBackupKeyViewedChange: action('onBackupKeyViewedChange'), + onBackupKeyViewed: action('onBackupKeyViewed'), onCallNotificationsChange: action('onCallNotificationsChange'), onCallRingtoneNotificationChange: action( 'onCallRingtoneNotificationChange' @@ -1253,7 +1254,7 @@ export const LocalBackups = Template.bind({}); LocalBackups.args = { settingsLocation: { page: SettingsPage.LocalBackups }, backupLocalBackupsEnabled: true, - backupKeyViewed: true, + previouslyViewedBackupKeyHash: 'hash', lastLocalBackup: { timestamp: Date.now() - DAY, backupsFolder: 'backups', @@ -1266,20 +1267,20 @@ export const LocalBackupsNeverBackedUp = Template.bind({}); LocalBackupsNeverBackedUp.args = { settingsLocation: { page: SettingsPage.LocalBackups }, backupLocalBackupsEnabled: true, - backupKeyViewed: true, + previouslyViewedBackupKeyHash: 'hash', lastLocalBackup: undefined, localBackupFolder: '/home/signaluser/Signal Backups/', }; export const LocalBackupsSetupChooseFolder = Template.bind({}); LocalBackupsSetupChooseFolder.args = { - settingsLocation: { page: SettingsPage.LocalBackupsSetupFolder }, + settingsLocation: { page: SettingsPage.LocalBackups }, backupLocalBackupsEnabled: true, }; export const LocalBackupsSetupViewBackupKey = Template.bind({}); LocalBackupsSetupViewBackupKey.args = { - settingsLocation: { page: SettingsPage.LocalBackupsSetupKey }, + settingsLocation: { page: SettingsPage.LocalBackups }, backupLocalBackupsEnabled: true, localBackupFolder: '/home/signaluser/Signal Backups/', }; diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index f957d850e8..582117aa8e 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -111,10 +111,11 @@ type SelectChangeHandlerType = (value: T) => unknown; export type PropsDataType = { // Settings - accountEntropyPool: string | undefined; + backupKey: string | undefined; + backupKeyHash: string | undefined; autoDownloadAttachment: AutoDownloadAttachmentType; backupFreeMediaDays: number; - backupKeyViewed: boolean; + previouslyViewedBackupKeyHash: string | undefined; backupLocalBackupsEnabled: boolean; backupTier: BackupLevel | null; lastLocalBackup: LocalBackupExportMetadata | undefined; @@ -313,7 +314,7 @@ type PropsFunctionType = { ) => unknown; onAutoDownloadUpdateChange: CheckboxChangeHandlerType; onAutoLaunchChange: CheckboxChangeHandlerType; - onBackupKeyViewedChange: (keyViewed: boolean) => void; + onBackupKeyViewed: ({ backupKeyHash }: { backupKeyHash: string }) => void; onCallNotificationsChange: CheckboxChangeHandlerType; onCallRingtoneNotificationChange: CheckboxChangeHandlerType; onContentProtectionChange: CheckboxChangeHandlerType; @@ -400,7 +401,6 @@ const DEFAULT_ZOOM_FACTORS = [ ]; export function Preferences({ - accountEntropyPool, addCustomColor, autoDownloadAttachment, availableCameras, @@ -412,7 +412,8 @@ export function Preferences({ resumeBackupMediaDownload, cancelBackupMediaDownload, backupFreeMediaDays, - backupKeyViewed, + backupKey, + backupKeyHash, backupTier, backupSubscriptionStatus, backupLocalBackupsEnabled, @@ -485,7 +486,7 @@ export function Preferences({ onAutoDownloadAttachmentChange, onAutoDownloadUpdateChange, onAutoLaunchChange, - onBackupKeyViewedChange, + onBackupKeyViewed, onCallNotificationsChange, onCallRingtoneNotificationChange, onContentProtectionChange, @@ -539,6 +540,7 @@ export function Preferences({ renderPreferencesEditChatFolderPage, openFileInFolder, osName, + previouslyViewedBackupKeyHash, promptOSAuth, resetAllChatColors, resetDefaultChatColor, @@ -2275,7 +2277,6 @@ export function Preferences({ pageTitle = i18n('icu:Preferences__local-backups'); } // Local backups setup page titles intentionally left blank - let backPage: PreferencesBackupPage | undefined; if (settingsLocation.page === SettingsPage.LocalBackupsKeyReference) { backPage = SettingsPage.LocalBackups; @@ -2295,9 +2296,9 @@ export function Preferences({ } const pageContents = ( void; + onBackupKeyViewed: ({ backupKeyHash }: { backupKeyHash: string }) => void; openFileInFolder: (path: string) => void; osName: 'linux' | 'macos' | 'windows' | undefined; settingsLocation: SettingsLocation; @@ -101,6 +100,7 @@ export function PreferencesBackups({ pauseBackupMediaDownload: () => void; resumeBackupMediaDownload: () => void; pickLocalBackupFolder: () => Promise; + previouslyViewedBackupKeyHash: string | undefined; promptOSAuth: ( reason: PromptOSAuthReasonType ) => Promise; @@ -152,19 +152,25 @@ export function PreferencesBackups({ } if (isLocalBackupsPage(settingsLocation.page)) { + if (!backupKey || !backupKeyHash) { + setSettingsLocation({ page: SettingsPage.Backups }); + return null; + } + return ( ); - const isLocalBackupsSetup = localBackupFolder && backupKeyViewed; + const isLocalBackupsSetup = + localBackupFolder != null && previouslyViewedBackupKeyHash != null; function renderRemoteBackups() { return ( diff --git a/ts/components/PreferencesLocalBackups.dom.tsx b/ts/components/PreferencesLocalBackups.dom.tsx index 0cbe5437fe..218ec4f920 100644 --- a/ts/components/PreferencesLocalBackups.dom.tsx +++ b/ts/components/PreferencesLocalBackups.dom.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, JSX } from 'react'; import React, { useCallback, useEffect, @@ -24,7 +24,6 @@ import { SettingsPage } from '../types/Nav.std.js'; import { ToastType } from '../types/Toast.dom.js'; import type { ShowToastAction } from '../state/ducks/toast.preload.js'; import { Modal } from './Modal.dom.js'; -import { strictAssert } from '../util/assert.std.js'; import type { PromptOSAuthReasonType, PromptOSAuthResultType, @@ -39,29 +38,31 @@ import { tw } from '../axo/tw.dom.js'; import { createLogger } from '../logging/log.std.js'; import { toLogFormat } from '../types/errors.std.js'; import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js'; +import { AxoSymbol } from '../axo/AxoSymbol.dom.js'; const { noop } = lodash; const log = createLogger('PreferencesLocalBackups'); export function PreferencesLocalBackups({ - accountEntropyPool, - backupKeyViewed, + backupKey, + backupKeyHash, disableLocalBackups, i18n, lastLocalBackup, localBackupFolder, openFileInFolder, osName, - onBackupKeyViewedChange, + onBackupKeyViewed, settingsLocation, pickLocalBackupFolder, + previouslyViewedBackupKeyHash, promptOSAuth, setSettingsLocation, showToast, startLocalBackupExport, }: { - accountEntropyPool: string | undefined; - backupKeyViewed: boolean; + backupKey: string; + backupKeyHash: string; disableLocalBackups: ({ deleteExistingBackups, }: { @@ -70,22 +71,25 @@ export function PreferencesLocalBackups({ i18n: LocalizerType; lastLocalBackup: LocalBackupExportMetadata | undefined; localBackupFolder: string | undefined; - onBackupKeyViewedChange: (keyViewed: boolean) => void; + onBackupKeyViewed: ({ backupKeyHash }: { backupKeyHash: string }) => void; openFileInFolder: (path: string) => void; osName: 'linux' | 'macos' | 'windows' | undefined; settingsLocation: SettingsLocation; pickLocalBackupFolder: () => Promise; + previouslyViewedBackupKeyHash: string | undefined; promptOSAuth: ( reason: PromptOSAuthReasonType ) => Promise; setSettingsLocation: (settingsLocation: SettingsLocation) => void; showToast: ShowToastAction; startLocalBackupExport: () => void; -}): React.JSX.Element { +}): React.JSX.Element | null { const [authError, setAuthError] = React.useState>(); const [isAuthPending, setIsAuthPending] = useState(false); const [isDisablePending, setIsDisablePending] = useState(false); + const [isShowingBackupKeyChangedModal, setIsShowingBackupKeyChangedModal] = + useState(false); if (!localBackupFolder) { return ( @@ -98,28 +102,44 @@ export function PreferencesLocalBackups({ const isReferencingBackupKey = settingsLocation.page === SettingsPage.LocalBackupsKeyReference; - if (!backupKeyViewed || isReferencingBackupKey) { - strictAssert(accountEntropyPool, 'AEP is required for backup key viewer'); - + if (!previouslyViewedBackupKeyHash || isReferencingBackupKey) { return ( { - if (backupKeyViewed) { - setSettingsLocation({ - page: SettingsPage.LocalBackups, - }); - } else { - onBackupKeyViewedChange(true); - } + onBackupKeyViewed({ backupKeyHash }); + setSettingsLocation({ + page: SettingsPage.LocalBackups, + }); }} showToast={showToast} /> ); } + async function showKeyReferenceWithAuth() { + setAuthError(undefined); + + try { + setIsAuthPending(true); + const result = await promptOSAuth('view-aep'); + if (result === 'success' || result === 'unsupported') { + setSettingsLocation({ + page: SettingsPage.LocalBackupsKeyReference, + }); + } else { + setAuthError(result); + } + } finally { + setIsAuthPending(false); + } + } + const learnMoreLink = (parts: Array) => ( {parts} @@ -170,7 +190,16 @@ export function PreferencesLocalBackups({ { + if ( + !previouslyViewedBackupKeyHash || + previouslyViewedBackupKeyHash !== backupKeyHash + ) { + setIsShowingBackupKeyChangedModal(true); + } else { + startLocalBackupExport(); + } + }} > {i18n('icu:Preferences__local-backups-backup-now')} @@ -225,20 +254,13 @@ export function PreferencesLocalBackups({ isAuthPending ? { 'aria-label': i18n('icu:loading') } : null } onClick={async () => { - setAuthError(undefined); - - try { - setIsAuthPending(true); - const result = await promptOSAuth('view-aep'); - if (result === 'success' || result === 'unsupported') { - setSettingsLocation({ - page: SettingsPage.LocalBackupsKeyReference, - }); - } else { - setAuthError(result); - } - } finally { - setIsAuthPending(false); + if ( + !previouslyViewedBackupKeyHash || + previouslyViewedBackupKeyHash !== backupKeyHash + ) { + setIsShowingBackupKeyChangedModal(true); + } else { + await showKeyReferenceWithAuth(); } }} > @@ -323,6 +345,48 @@ export function PreferencesLocalBackups({ ) : null} + + {isShowingBackupKeyChangedModal ? ( + { + if (!open) { + setIsShowingBackupKeyChangedModal(false); + } + }} + > +
+ + +
+ + +
+ {i18n('icu:Preferences__recovery-key-updated__title')} +
+
+
+ +
+ {i18n('icu:Preferences__recovery-key-updated__description')} +
+
+
+ + + {i18n('icu:cancel')} + + + {i18n('icu:Preferences__recovery-key-updated__view-key')} + + +
+
+
+ ) : null} ); } @@ -478,13 +542,13 @@ function LocalBackupsSetupFolderPicker({ type BackupKeyStep = 'view' | 'confirm' | 'caution' | 'reference'; function LocalBackupsBackupKeyViewer({ - accountEntropyPool, + backupKey, i18n, isReferencing, onBackupKeyViewed, showToast, }: { - accountEntropyPool: string; + backupKey: string; i18n: LocalizerType; isReferencing: boolean; onBackupKeyViewed: () => void; @@ -497,20 +561,23 @@ function LocalBackupsBackupKeyViewer({ ); const isStepViewOrReference = step === 'view' || step === 'reference'; - const backupKey = useMemo(() => { - return accountEntropyPool + const backupKeyForDisplay = useMemo(() => { + return backupKey .replace(/\s/g, '') .replace(/.{4}(?=.)/g, '$& ') .toUpperCase(); - }, [accountEntropyPool]); + }, [backupKey]); const onCopyBackupKey = useCallback( async function handleCopyBackupKey(e: React.MouseEvent) { e.preventDefault(); - await window.SignalClipboard.copyTextTemporarily(backupKey, 45 * SECOND); + window.SignalClipboard.copyTextTemporarily( + backupKeyForDisplay, + 45 * SECOND + ); showToast({ toastType: ToastType.CopiedBackupKey }); }, - [backupKey, showToast] + [backupKeyForDisplay, showToast] ); const learnMoreLink = (parts: Array) => ( @@ -621,7 +688,7 @@ function LocalBackupsBackupKeyViewer({
setIsBackupKeyConfirmed(isValid)} isStepViewOrReference={isStepViewOrReference} @@ -726,3 +793,16 @@ function getOSAuthErrorString( return i18n('icu:Preferences__local-backups-auth-error--unavailable'); } + +function LocalBackupSetupIcon(props: { symbol: 'key' | 'lock' }): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/ts/services/backups/crypto.preload.ts b/ts/services/backups/crypto.preload.ts index 40d89155cf..a3d7342c0f 100644 --- a/ts/services/backups/crypto.preload.ts +++ b/ts/services/backups/crypto.preload.ts @@ -13,6 +13,7 @@ import { strictAssert } from '../../util/assert.std.js'; import type { AciString } from '../../types/ServiceId.std.js'; import { toAciObject } from '../../util/ServiceId.node.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { sha256 } from '../../Crypto.node.js'; const getMemoizedBackupKey = memoizee((accountEntropyPool: string) => { return AccountEntropyPool.deriveBackupKey(accountEntropyPool); @@ -25,6 +26,10 @@ export function getBackupKey(): BackupKey { return getMemoizedBackupKey(accountEntropyPool); } +export function getBackupKeyHash(backupKey: string): string { + return Buffer.from(sha256(Buffer.from(backupKey))).toString('base64'); +} + export function getBackupMediaRootKey(): BackupKey { const rootKey = itemStorage.get('backupMediaRootKey'); strictAssert(rootKey, 'Media root key not available'); diff --git a/ts/services/backups/index.preload.ts b/ts/services/backups/index.preload.ts index 4f997f8cd8..7ae573e8c1 100644 --- a/ts/services/backups/index.preload.ts +++ b/ts/services/backups/index.preload.ts @@ -1468,7 +1468,7 @@ export class BackupsService { await Promise.all([ itemStorage.remove('lastLocalBackup'), itemStorage.remove('localBackupFolder'), - itemStorage.remove('backupKeyViewed'), + itemStorage.remove('backupKeyViewedHash'), ]); if (deleteExistingBackups) { diff --git a/ts/state/selectors/items.dom.ts b/ts/state/selectors/items.dom.ts index 6e71b40381..492d2ec9bf 100644 --- a/ts/state/selectors/items.dom.ts +++ b/ts/state/selectors/items.dom.ts @@ -303,6 +303,11 @@ export const getBackupMediaDownloadProgress = createSelector( }) ); +export const getBackupKey = createSelector( + getItems, + (state: ItemsStateType) => state.accountEntropyPool +); + export const getServerAlerts = createSelector( getItems, (state: ItemsStateType) => state.serverAlerts ?? {} diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index ad91b306ce..3703fe8114 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -1,7 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { StrictMode, useCallback, useEffect } from 'react'; +import React, { StrictMode, useCallback, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import type { AudioDevice } from '@signalapp/ringrtc'; @@ -15,6 +15,7 @@ import { getOtherTabsUnreadStats, } from '../selectors/conversations.dom.js'; import { + getBackupKey, getCustomColors, getItems, getNavTabsCollapsed, @@ -118,6 +119,7 @@ import type { ExternalProps as SmartNotificationProfilesProps } from './Preferen import { useMegaphonesActions } from '../ducks/megaphones.preload.js'; import type { ZoomFactorType } from '../../types/StorageKeys.std.js'; import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.preload.js'; +import { getBackupKeyHash } from '../../services/backups/crypto.preload.js'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -259,6 +261,14 @@ export function SmartPreferences(): React.JSX.Element | null { ); const { osName } = useSelector(getUser); + const backupKey = useSelector(getBackupKey); + const backupKeyHash = useMemo(() => { + if (!backupKey) { + return undefined; + } + return getBackupKeyHash(backupKey); + }, [backupKey]); + // The weird ones const makeSyncRequest = async () => { @@ -552,6 +562,10 @@ export function SmartPreferences(): React.JSX.Element | null { await window.IPC.setMediaPermissions(value); }; + const onBackupKeyViewed = (args: { backupKeyHash: string }) => { + onBackupKeyViewedChange(args.backupKeyHash); + }; + // Simple, one-way items const { @@ -622,10 +636,9 @@ export function SmartPreferences(): React.JSX.Element | null { 'auto-download-attachment', DEFAULT_AUTO_DOWNLOAD_ATTACHMENT ); - const [backupKeyViewed, onBackupKeyViewedChange] = createItemsAccess( - 'backupKeyViewed', - false - ); + + const [previouslyViewedBackupKeyHash, onBackupKeyViewedChange] = + createItemsAccess('backupKeyViewedHash', undefined); const [hasAudioNotifications, onAudioNotificationsChange] = createItemsAccess( 'audio-notification', @@ -816,20 +829,18 @@ export function SmartPreferences(): React.JSX.Element | null { }); }; - const accountEntropyPool = itemStorage.get('accountEntropyPool'); - return ( ; const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [