diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fce296b5d2..f44348e2b3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -786,10 +786,66 @@ "messageformat": "To verify end-to-end encryption with {name}, compare the numbers above with their device. They can also scan your code with their device.", "description": "Safety number viewer, text of the hint" }, + "icu:SafetyNumberViewer__hint-v2": { + "messageformat": "To verify end-to-end encryption with {name}, compare the numbers above with their device. They can also scan your code on their device.", + "description": "Safety number viewer, text of the hint" + }, "icu:SafetyNumberViewer__learn_more": { "messageformat": "Learn more", "description": "Text of 'Learn more' button of SafetyNumberViewerModal modal" }, + "icu:SafetyNumberViewer__KeyTransparency__title": { + "messageformat": "Automatic key verification", + "description": "Title of Key Transparency section of SafetyNumberViewer" + }, + "icu:SafetyNumberViewer__KeyTransparency__button--idle": { + "messageformat": "Verify automatically", + "description": "Title of Key Transparency button in SafetyNumberViewer when check is available" + }, + "icu:SafetyNumberViewer__KeyTransparency__button--running": { + "messageformat": "Verifying encryption...", + "description": "Title of Key Transparency button in SafetyNumberViewer when check is running" + }, + "icu:SafetyNumberViewer__KeyTransparency__button--ok": { + "messageformat": "Encryption verified", + "description": "Title of Key Transparency button in SafetyNumberViewer when check is successful" + }, + "icu:SafetyNumberViewer__KeyTransparency__button--fail": { + "messageformat": "Auto-verification unavailable", + "description": "Title of Key Transparency button in SafetyNumberViewer when check is not successful" + }, + "icu:SafetyNumberViewer__KeyTransparency__hint": { + "messageformat": "Auto-verification is not available for all chats.", + "description": "Hint for Key Transparency section of SafetyNumberViewer" + }, + "icu:SafetyNumberViewer__KeyTransparency__learn_more": { + "messageformat": "Learn more", + "description": "Text of 'Learn more' button of SafetyNumberViewerModal modal's Key Transparency section" + }, + "icu:SafetyNumberViewer__KeyTransparency__popup--ok__title": { + "messageformat": "Encryption was auto-verified for this chat ", + "description": "A title of an education modal in Key Transparency section of SafetyNumberViewer when verification was successful" + }, + "icu:SafetyNumberViewer__KeyTransparency__popup--ok__body": { + "messageformat": "For contacts you’re connected to by phone number, Signal can automatically confirm whether the connection is secure using a process called key transparency. For added security, verify end-to-end encryption manually by comparing the numbers on the previous screen or scanning the code on their device.", + "description": "A body of an education modal in Key Transparency section of SafetyNumberViewer when verification was successful" + }, + "icu:SafetyNumberViewer__KeyTransparency__popup--fail__title": { + "messageformat": "Auto-verification is no longer available for this chat", + "description": "A title of an education modal in Key Transparency section of SafetyNumberViewer when verification was unsuccessful" + }, + "icu:SafetyNumberViewer__KeyTransparency__popup--fail__body": { + "messageformat": "Signal can no longer automatically verify the encryption for this chat. This is likely because Katie Hall changed their phone number. Verify end-to-end encryption manually by comparing the numbers on the previous screen or scanning the code on their device.", + "description": "A body of an education modal in Key Transparency section of SafetyNumberViewer when verification was unsuccessful" + }, + "icu:SafetyNumberViewer__KeyTransparency__popup--unavailable__body": { + "messageformat": "Signal can only automatically verify the encryption in chats where you’re connected to someone via a phone number. If the chat was started with a username or a group in common, verify end-to-end encryption by comparing the numbers on the previous screen or scanning the code on their device.", + "description": "A body of an education modal in Key Transparency section of SafetyNumberViewer when verification is unavailable" + }, + "icu:SafetyNumberViewer__KeyTransparency__popup__okay": { + "messageformat": "Okay", + "description": "A dismiss button text for an education modal in Key Transparency section" + }, "icu:SafetyNumberNotReady__body": { "messageformat": "A safety number will be created with this person after you exchange messages with them.", "description": "Body of SafetyNumberNotReady modal" @@ -798,6 +854,58 @@ "messageformat": "Learn more", "description": "Text of 'Learn more' button of SafetyNumberNotReady modal" }, + "icu:KeyTransparencyErrorDialog__Title": { + "messageformat": "Automatic Key Verification is currently unavailable for your device. Submit debug log?", + "description": "Key Transparency Error Dialog > Title" + }, + "icu:KeyTransparencyErrorDialog__Description": { + "messageformat": "Debug logs helps us diagnose and fix the issue, and do not contain identifying information.", + "description": "Key Transparency Error Dialog > Description" + }, + "icu:KeyTransparencyErrorDialog__CloseButton__AccessibilityLabel": { + "messageformat": "Close", + "description": "Key Transparency Error Dialog > Dialog Close Button (Accessibility Label)" + }, + "icu:KeyTransparencyErrorDialog__ShareDebugLog__Label": { + "messageformat": "Share debug log", + "description": "Key Transparency Error Dialog > Share debug log > Label" + }, + "icu:KeyTransparencyErrorDialog__ShareDebugLog__ViewButton": { + "messageformat": "View", + "description": "Key Transparency Error Dialog > Share debug log > View Button" + }, + "icu:KeyTransparencyErrorDialog__Submit": { + "messageformat": "Submit", + "description": "Primary button text in the dialog shown when an unexpected key transparency error occurs. Clicking it will open a support page" + }, + "icu:KeyTransparencyErrorDialog__Cancel": { + "messageformat": "No thanks", + "description": "Secondary button text in the dialog shown when an unexpected key transparency error occurs. Clicking the button will dismiss the error dialog." + }, + "icu:KeyTransparencyErrorDialog__Submitting": { + "messageformat": "Submitting...", + "description": "Key Transparency Error Dialog > Accessibility label for loading spinner while submitting" + }, + "icu:KeyTransparencyOnboardingDialog__Title": { + "messageformat": "Signal can now auto-verify key encryption", + "description": "Key Transparency Onboarding Dialog > Title" + }, + "icu:KeyTransparencyOnboardingDialog__Description": { + "messageformat": "For contacts you’re connected to by phone number, Signal can automatically confirm whether the connection is secure using a process called key transparency. For added security, you can still verify connections manually using a QR code or number.", + "description": "Key Transparency Onboarding Dialog > Description" + }, + "icu:KeyTransparencyOnboardingDialog__Continue": { + "messageformat": "Continue", + "description": "Key Transparency Onboarding Dialog > Text of the primary button that takes user to safety number dialog" + }, + "icu:KeyTransparencyOnboardingDialog__LearnMore": { + "messageformat": "Learn more", + "description": "Key Transparency Onboarding Dialog > Text of the secondary button that opens a support page in the browser" + }, + "icu:KeyTransparencyOnboardingDialog__CloseButton__AccessibilityLabel": { + "messageformat": "Close", + "description": "Key Transparency Onboarding Dialog > Dialog Close Button (Accessibility Label)" + }, "icu:verified": { "messageformat": "Verified" }, diff --git a/images/caption-shadow.svg b/images/caption-shadow.svg index 801cfdb135..6f004a61a8 100644 --- a/images/caption-shadow.svg +++ b/images/caption-shadow.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/key-transparency-dark.svg b/images/key-transparency-dark.svg new file mode 100644 index 0000000000..57f8622219 --- /dev/null +++ b/images/key-transparency-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/key-transparency-light.svg b/images/key-transparency-light.svg new file mode 100644 index 0000000000..4a13166752 --- /dev/null +++ b/images/key-transparency-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/notifications-moon.svg b/images/notifications-moon.svg index f13c3a0d7b..a759a43b1c 100644 --- a/images/notifications-moon.svg +++ b/images/notifications-moon.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 2134b9deb6..24c297bf39 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2172,10 +2172,6 @@ $timer-icons: font-weight: bold; } -.module-safety-number__bold-name { - font-weight: bold; -} - // Module: Error Boundary .module-error-boundary-notification { diff --git a/stylesheets/components/SafetyNumberViewer.scss b/stylesheets/components/SafetyNumberViewer.scss deleted file mode 100644 index 3af9cbc339..0000000000 --- a/stylesheets/components/SafetyNumberViewer.scss +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -@use '../mixins'; -@use '../variables'; - -.module-SafetyNumberViewer { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 20px; - padding-top: 16px; - padding-inline: 10px; - - a { - text-decoration: none; - } - - &__card-container { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - } - - &__card { - display: flex; - flex-direction: column; - gap: 16px; - align-items: center; - max-width: 248px; - - padding: 24px; - border-radius: 12px; - - background-color: variables.$color-borage-blue; - - &__qr { - width: 120px; - height: 120px; - padding: 10px; - border-radius: 8px; - background: variables.$color-white; - } - - &__number { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: center; - color: variables.$color-white; - - font-family: variables.$monospace; - margin-block: 0 4px; - - @include mixins.keyboard-mode { - &:focus { - box-shadow: 0 0 0 3px variables.$color-ultramarine; - } - } - } - } - - &__help { - @include mixins.font-subtitle; - @include mixins.light-theme { - color: variables.$color-gray-60; - } - - @include mixins.dark-theme { - color: variables.$color-gray-25; - } - - & { - margin-top: 4px; - } - } - - &__verification-status { - margin-block: 30px 10px; - margin-inline: 0; - text-align: center; - } - - &__button { - margin-block: 0 16px; - } - - &__buttons { - text-align: end; - } - - &__modal.module-Modal { - max-width: 500px; - } -} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 6b88721334..77c82153cf 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -163,7 +163,6 @@ @use 'components/RemoteMegaphoneTooltip.scss'; @use 'components/SafetyNumberChangeDialog.scss'; @use 'components/SafetyNumberOnboarding.scss'; -@use 'components/SafetyNumberViewer.scss'; @use 'components/SafetyTipsModal.scss'; @use 'components/ScrollDownButton.scss'; @use 'components/SearchInput.scss'; diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index 8a29376a01..0bdcf74e08 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -282,6 +282,7 @@ --font-weight-semibold: 600; --font-weight-medium: 500; --font-weight-regular: 400; + --font-weight-light: 300; --type-font-weight-title-large: var(--font-weight-semibold); --type-font-weight-title-medium: var(--font-weight-semibold); --type-font-weight-title-small: var(--font-weight-semibold); diff --git a/ts/LibSignalStores.preload.ts b/ts/LibSignalStores.preload.ts index e17661670e..7a42a022a4 100644 --- a/ts/LibSignalStores.preload.ts +++ b/ts/LibSignalStores.preload.ts @@ -6,6 +6,7 @@ import lodash from 'lodash'; import type { + Aci, Direction, KyberPreKeyRecord, PreKeyRecord, @@ -26,6 +27,7 @@ import { SessionStore, SignedPreKeyStore, } from '@signalapp/libsignal-client'; +import type { Store as KeyTransparencyStoreInterface } from '@signalapp/libsignal-client/dist/net/KeyTransparency.d.ts'; import { Address } from './types/Address.std.js'; import { QualifiedAddress } from './types/QualifiedAddress.std.js'; import type { ServiceIdString } from './types/ServiceId.std.js'; @@ -328,3 +330,23 @@ export class SignedPreKeys extends SignedPreKeyStore { return signedPreKey; } } + +export class KeyTransparencyStore implements KeyTransparencyStoreInterface { + async getLastDistinguishedTreeHead(): Promise { + return signalProtocolStore.getLastDistinguishedTreeHead(); + } + + async setLastDistinguishedTreeHead( + bytes: Readonly | null + ): Promise { + return signalProtocolStore.setLastDistinguishedTreeHead(bytes); + } + + async getAccountData(aci: Aci): Promise { + return signalProtocolStore.getKTAccountData(aci); + } + + async setAccountData(aci: Aci, bytes: Readonly): Promise { + return signalProtocolStore.setKTAccountData(aci, bytes); + } +} diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts index 14dbd21428..a80f930911 100644 --- a/ts/RemoteConfig.dom.ts +++ b/ts/RemoteConfig.dom.ts @@ -40,6 +40,8 @@ const SemverKeys = [ 'desktop.plaintextExport.prod', 'desktop.retireAccessKeyGroupSend.beta', 'desktop.retireAccessKeyGroupSend.prod', + 'desktop.keyTransparency.beta', + 'desktop.keyTransparency.prod', ] as const; export type SemverKeyType = ArrayValues; diff --git a/ts/SignalProtocolStore.preload.ts b/ts/SignalProtocolStore.preload.ts index 9946cbfbc9..d1af40d82f 100644 --- a/ts/SignalProtocolStore.preload.ts +++ b/ts/SignalProtocolStore.preload.ts @@ -6,6 +6,7 @@ import lodash from 'lodash'; import { z } from 'zod'; import { EventEmitter } from 'node:events'; +import type { Aci } from '@signalapp/libsignal-client'; import { Direction, IdentityChange, @@ -54,7 +55,11 @@ import type { PniString, AciString, } from './types/ServiceId.std.js'; -import { isServiceIdString, ServiceIdKind } from './types/ServiceId.std.js'; +import { + isServiceIdString, + ServiceIdKind, + fromAciObject, +} from './types/ServiceId.std.js'; import type { Address } from './types/Address.std.js'; import type { QualifiedAddressStringType } from './types/QualifiedAddress.std.js'; import { QualifiedAddress } from './types/QualifiedAddress.std.js'; @@ -2796,6 +2801,36 @@ export class SignalProtocolStore extends EventEmitter { } } + // Key Transparency + + getLastDistinguishedTreeHead(): Uint8Array | null { + return itemStorage.get('lastDistinguishedTreeHead') ?? null; + } + + async setLastDistinguishedTreeHead( + bytes: Readonly | null + ): Promise { + if (bytes == null) { + await itemStorage.remove('lastDistinguishedTreeHead'); + } else { + await itemStorage.put('lastDistinguishedTreeHead', bytes); + } + } + + async getKTAccountData(aciObject: Aci): Promise { + const aci = fromAciObject(aciObject); + const data = await DataReader.getKTAccountData(aci); + return data ?? null; + } + + async setKTAccountData( + aciObject: Aci, + bytes: Readonly + ): Promise { + const aci = fromAciObject(aciObject); + return DataWriter.setKTAccountData(aci, bytes); + } + // // EventEmitter types // diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index 2e0f956f90..8208af96b9 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -63,12 +63,13 @@ export namespace AxoDialog { }>; const ContentSizes: Record = { + xs: { width: 300, minWidth: 300 }, sm: { width: 360, minWidth: 360 }, md: { width: 420, minWidth: 360 }, lg: { width: 720, minWidth: 360 }, }; - export type ContentSize = 'sm' | 'md' | 'lg'; + export type ContentSize = 'xs' | 'sm' | 'md' | 'lg'; export type ContentEscape = AxoBaseDialog.ContentEscape; export type ContentProps = Readonly<{ size: ContentSize; @@ -235,11 +236,12 @@ export namespace AxoDialog { export type BodyProps = Readonly<{ padding?: BodyPadding; + maxHeight?: number; children: ReactNode; }>; export const Body: FC = memo(props => { - const { padding = 'normal' } = props; + const { padding = 'normal', maxHeight = 440 } = props; const style = useMemo((): CSSProperties | undefined => { if (padding === 'only-scrollbar-gutter') { @@ -253,7 +255,7 @@ export namespace AxoDialog { return ( diff --git a/ts/background.preload.ts b/ts/background.preload.ts index d1d7b4271e..408d08b5b7 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -46,6 +46,7 @@ import { initialize as initializeExpiringMessageService, update as updateExpiringMessagesService, } from './services/expiringMessagesDeletion.preload.js'; +import { keyTransparency } from './services/keyTransparency.preload.js'; import { initialize as initializeNotificationProfilesService, fastUpdate as updateNotificationProfileService, @@ -477,6 +478,7 @@ export async function startApp(): Promise { drop(itemStorage.put('postRegistrationSyncsStatus', 'incomplete')); registrationCompleted?.resolve(); drop(Registration.markDone()); + drop(keyTransparency.onRegistrationDone()); }); const cancelInitializationMessage = setAppLoadingScreenMessage( @@ -1242,6 +1244,8 @@ export async function startApp(): Promise { log.info('reconnecting websocket on user change'); enqueueReconnectToWebSocket(); } + + drop(keyTransparency.onKnownIdentifierChange()); }); window.Whisper.events.on('setMenuOptions', (options: MenuOptionsType) => { @@ -1387,6 +1391,7 @@ export async function startApp(): Promise { initializeExpiringMessageService(); initializeNotificationProfilesService(); + keyTransparency.start(); log.info('Blocked uuids cleanup: starting...'); const blockedUuids = itemStorage.get(BLOCKED_UUIDS_ID, []); diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx index 0fead9d751..716f283e6c 100644 --- a/ts/components/GlobalModalContainer.dom.tsx +++ b/ts/components/GlobalModalContainer.dom.tsx @@ -34,6 +34,7 @@ import { import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal.preload.js'; import { CriticalIdlePrimaryDeviceModal } from './CriticalIdlePrimaryDeviceModal.dom.js'; import { LowDiskSpaceBackupImportModal } from './LowDiskSpaceBackupImportModal.dom.js'; +import { KeyTransparencyOnboardingDialog } from './KeyTransparencyOnboardingDialog.dom.js'; import { isUsernameValid } from '../util/Username.dom.js'; import type { PinMessageDialogData } from '../state/smart/PinMessageDialog.preload.js'; @@ -136,6 +137,13 @@ export type PropsType = { // StoriesSettings isStoriesSettingsVisible: boolean; renderStoriesSettings: () => React.JSX.Element; + // KeyTransparencyErrorDialog + isKeyTransparencyErrorVisible: boolean; + renderKeyTransparencyErrorDialog: () => React.JSX.Element; + // KeyTransparencyOnboardingDialog + isKeyTransparencyOnboardingVisible: boolean; + hideKeyTransparencyOnboardingDialog: () => void; + finishKeyTransparencyOnboarding: () => void; // SendAnywayDialog hasSafetyNumberChangeModal: boolean; safetyNumberChangedBlockingData: @@ -249,6 +257,13 @@ export function GlobalModalContainer({ // StoriesSettings isStoriesSettingsVisible, renderStoriesSettings, + // KeyTransparencyErrorDialog + isKeyTransparencyErrorVisible, + renderKeyTransparencyErrorDialog, + // KeyTransparencyOnboardingDialog + isKeyTransparencyOnboardingVisible, + hideKeyTransparencyOnboardingDialog, + finishKeyTransparencyOnboarding, // SendAnywayDialog hasSafetyNumberChangeModal, safetyNumberChangedBlockingData, @@ -309,6 +324,10 @@ export function GlobalModalContainer({ return renderDebugLogErrorModal(debugLogErrorModalProps); } + if (isKeyTransparencyErrorVisible) { + return renderKeyTransparencyErrorDialog(); + } + // Safety Number if (hasSafetyNumberChangeModal || safetyNumberChangedBlockingData) { return renderSendAnywayDialog(); @@ -398,6 +417,22 @@ export function GlobalModalContainer({ ); } + // Intentionally above safety number since that causes onboarding flow + if (isKeyTransparencyOnboardingVisible) { + return ( + { + if (!open) { + hideKeyTransparencyOnboardingDialog(); + } + }} + onContinue={finishKeyTransparencyOnboarding} + /> + ); + } + if (safetyNumberModalContactId) { return renderSafetyNumber(); } diff --git a/ts/components/KeyTransparencyErrorDialog.dom.stories.tsx b/ts/components/KeyTransparencyErrorDialog.dom.stories.tsx new file mode 100644 index 0000000000..4cac030aec --- /dev/null +++ b/ts/components/KeyTransparencyErrorDialog.dom.stories.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Meta } from '@storybook/react'; +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { KeyTransparencyErrorDialog } from './KeyTransparencyErrorDialog.dom.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/KeyTransparencyErrorDialog', +} satisfies Meta; + +export function Default(): React.JSX.Element { + const [open, setOpen] = useState(true); + return ( + + ); +} diff --git a/ts/components/KeyTransparencyErrorDialog.dom.tsx b/ts/components/KeyTransparencyErrorDialog.dom.tsx new file mode 100644 index 0000000000..1625ba8568 --- /dev/null +++ b/ts/components/KeyTransparencyErrorDialog.dom.tsx @@ -0,0 +1,98 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { useCallback, useId, useState } from 'react'; +import type { LocalizerType } from '../types/I18N.std.js'; +import { AxoButton } from '../axo/AxoButton.dom.js'; +import { AxoDialog } from '../axo/AxoDialog.dom.js'; +import { tw } from '../axo/tw.dom.js'; +import { AxoCheckbox } from '../axo/AxoCheckbox.dom.js'; +import { I18n } from './I18n.dom.js'; + +export type KeyTransparencyErrorDialogProps = Readonly<{ + i18n: LocalizerType; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (shareDebugLog: boolean) => void; + onViewDebugLog: () => void; + isSubmitting: boolean; +}>; + +export function KeyTransparencyErrorDialog( + props: KeyTransparencyErrorDialogProps +): React.JSX.Element { + const { i18n, open, onOpenChange, onViewDebugLog, onSubmit, isSubmitting } = + props; + + const debugLogCheckboxId = useId(); + const [shareDebugLog, setShareDebugLog] = useState(false); + + const handleSubmit = useCallback(() => { + onSubmit(shareDebugLog); + }, [onSubmit, shareDebugLog]); + + return ( + + + + + {i18n('icu:KeyTransparencyErrorDialog__Title')} + + + + +

+ + + +

+
+ + + + {i18n( + 'icu:KeyTransparencyErrorDialog__ShareDebugLog__ViewButton' + )} + +
+
+ + + + {i18n('icu:KeyTransparencyErrorDialog__Submit')} + + + +
+
+ ); +} diff --git a/ts/components/KeyTransparencyOnboardingDialog.dom.stories.tsx b/ts/components/KeyTransparencyOnboardingDialog.dom.stories.tsx new file mode 100644 index 0000000000..943261b069 --- /dev/null +++ b/ts/components/KeyTransparencyOnboardingDialog.dom.stories.tsx @@ -0,0 +1,24 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Meta } from '@storybook/react'; +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { KeyTransparencyOnboardingDialog } from './KeyTransparencyOnboardingDialog.dom.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/KeyTransparencyOnboardingDialog', +} satisfies Meta; + +export function Default(): React.JSX.Element { + const [open, setOpen] = useState(true); + return ( + + ); +} diff --git a/ts/components/KeyTransparencyOnboardingDialog.dom.tsx b/ts/components/KeyTransparencyOnboardingDialog.dom.tsx new file mode 100644 index 0000000000..e7bc05658b --- /dev/null +++ b/ts/components/KeyTransparencyOnboardingDialog.dom.tsx @@ -0,0 +1,78 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React from 'react'; +import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser.dom.js'; +import { KEY_TRANSPARENCY_URL } from '../types/support.std.js'; +import type { LocalizerType } from '../types/I18N.std.js'; +import { AxoDialog } from '../axo/AxoDialog.dom.js'; +import { tw } from '../axo/tw.dom.js'; + +export type KeyTransparencyOnboardingDialogProps = Readonly<{ + i18n: LocalizerType; + open: boolean; + onOpenChange: (open: boolean) => void; + onContinue: () => void; +}>; + +function openKeyTransparencyUrl() { + openLinkInWebBrowser(KEY_TRANSPARENCY_URL); +} + +export function KeyTransparencyOnboardingDialog( + props: KeyTransparencyOnboardingDialogProps +): React.JSX.Element { + const { i18n, open, onOpenChange, onContinue } = props; + + return ( + + + + + + +
+ + +
+

+ {i18n('icu:KeyTransparencyOnboardingDialog__Title')} +

+ +
+ {i18n('icu:KeyTransparencyOnboardingDialog__Description')} +
+
+
+ + + + {i18n('icu:KeyTransparencyOnboardingDialog__LearnMore')} + + + {i18n('icu:KeyTransparencyOnboardingDialog__Continue')} + + + +
+
+ ); +} diff --git a/ts/components/SafetyNumberChangeDialog.dom.stories.tsx b/ts/components/SafetyNumberChangeDialog.dom.stories.tsx index a825cf59a6..3a82aee5f7 100644 --- a/ts/components/SafetyNumberChangeDialog.dom.stories.tsx +++ b/ts/components/SafetyNumberChangeDialog.dom.stories.tsx @@ -4,13 +4,17 @@ import * as React from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; -import type { Props } from './SafetyNumberChangeDialog.dom.js'; +import type { + Props, + SafetyNumberProps, +} from './SafetyNumberChangeDialog.dom.js'; import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog.dom.js'; import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.js'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext.std.js'; import { getFakeBadge } from '../test-helpers/getFakeBadge.std.js'; import { MY_STORY_ID } from '../types/Stories.std.js'; import { generateStoryDistributionId } from '../types/StoryDistributionId.std.js'; +import { SafetyNumber } from './SafetyNumberViewer.dom.stories.js'; const { i18n } = window.SignalContext; @@ -58,6 +62,10 @@ export default { title: 'Components/SafetyNumberChangeDialog', } satisfies Meta; +function renderSafetyNumber({ onClose }: SafetyNumberProps): JSX.Element { + return ; +} + export function SingleContactDialog(): React.JSX.Element { const theme = useTheme(); return ( @@ -73,10 +81,7 @@ export function SingleContactDialog(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -98,10 +103,7 @@ export function DifferentConfirmationText(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -126,10 +128,7 @@ export function MultiContactDialog(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -153,10 +152,7 @@ export function AllVerified(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -182,10 +178,7 @@ export function MultipleContactsAllWithBadges(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -217,10 +210,7 @@ export function TenContacts(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -253,10 +243,7 @@ export function NoContacts(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); @@ -304,10 +291,7 @@ export function InMultipleStories(): React.JSX.Element { onCancel={action('cancel')} onConfirm={action('confirm')} removeFromStory={action('removeFromStory')} - renderSafetyNumber={() => { - action('renderSafetyNumber'); - return
This is a mock Safety Number View
; - }} + renderSafetyNumber={renderSafetyNumber} theme={theme} /> ); diff --git a/ts/components/SafetyNumberModal.dom.stories.tsx b/ts/components/SafetyNumberModal.dom.stories.tsx new file mode 100644 index 0000000000..8632b367f8 --- /dev/null +++ b/ts/components/SafetyNumberModal.dom.stories.tsx @@ -0,0 +1,41 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { Meta } from '@storybook/react'; +import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.js'; +import type { PropsType } from './SafetyNumberModal.dom.js'; +import { SafetyNumberModal } from './SafetyNumberModal.dom.js'; +import { SafetyNumber } from './SafetyNumberViewer.dom.stories.js'; + +const { i18n } = window.SignalContext; + +const contactWithAllData = getDefaultConversation({ + id: 'abc', + avatarUrl: undefined, + profileName: '-*Smartest Dude*-', + title: 'Rick Sanchez', + name: 'Rick Sanchez', + phoneNumber: '(305) 123-4567', +}); + +function renderSafetyNumberViewer(): JSX.Element { + return ; +} + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + i18n, + contact: contactWithAllData, + ...overrideProps, + toggleSafetyNumberModal: action('toggle-safety-number-modal'), + renderSafetyNumberViewer, +}); + +export default { + title: 'Components/SafetyNumberModal', +} satisfies Meta; + +export function Default(): React.JSX.Element { + return ; +} diff --git a/ts/components/SafetyNumberModal.dom.tsx b/ts/components/SafetyNumberModal.dom.tsx index 571dfc8b5b..4623e4c816 100644 --- a/ts/components/SafetyNumberModal.dom.tsx +++ b/ts/components/SafetyNumberModal.dom.tsx @@ -4,22 +4,25 @@ import React from 'react'; import { isSafetyNumberNotAvailable } from '../util/isSafetyNumberNotAvailable.std.js'; -import { Modal } from './Modal.dom.js'; -import type { PropsType as SafetyNumberViewerPropsType } from './SafetyNumberViewer.dom.js'; -import { SafetyNumberViewer } from './SafetyNumberViewer.dom.js'; +import type { ConversationType } from '../state/ducks/conversations.preload.js'; +import type { LocalizerType } from '../types/Util.std.js'; +import { AxoDialog } from '../axo/AxoDialog.dom.js'; +import type { SafetyNumberProps as SafetyNumberViewerPropsType } from './SafetyNumberChangeDialog.dom.js'; import { SafetyNumberNotReady } from './SafetyNumberNotReady.dom.js'; -type PropsType = { +export type PropsType = Readonly<{ + i18n: LocalizerType; + contact: ConversationType; toggleSafetyNumberModal: () => unknown; -} & Omit; + renderSafetyNumberViewer: (props: SafetyNumberViewerPropsType) => JSX.Element; +}>; export function SafetyNumberModal({ i18n, + contact, toggleSafetyNumberModal, - ...safetyNumberViewerProps + renderSafetyNumberViewer, }: PropsType): React.JSX.Element | null { - const { contact } = safetyNumberViewerProps; - let title: string | undefined; let content: React.JSX.Element; let hasXButton = true; @@ -34,25 +37,28 @@ export function SafetyNumberModal({ } else { title = i18n('icu:SafetyNumberModal__title'); - content = ( - - ); + content = renderSafetyNumberViewer({ + contactID: contact.id, + onClose: toggleSafetyNumberModal, + }); } return ( - { + if (!open) { + toggleSafetyNumberModal(); + } + }} > - {content} - + + + {title} + {hasXButton && } + + {content} + + ); } diff --git a/ts/components/SafetyNumberViewer.dom.stories.tsx b/ts/components/SafetyNumberViewer.dom.stories.tsx index 3ab8ff612d..1936ace434 100644 --- a/ts/components/SafetyNumberViewer.dom.stories.tsx +++ b/ts/components/SafetyNumberViewer.dom.stories.tsx @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as React from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { PropsType } from './SafetyNumberViewer.dom.js'; @@ -64,7 +64,6 @@ const contactWithNothing = getDefaultConversation({ const createProps = (overrideProps: Partial = {}): PropsType => ({ contact: overrideProps.contact || contactWithAllData, - generateSafetyNumber: action('generate-safety-number'), i18n, safetyNumber: 'safetyNumber' in overrideProps @@ -78,15 +77,20 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ overrideProps.verificationDisabled !== undefined ? overrideProps.verificationDisabled : false, - onClose: action('onClose'), + keyTransparencyStatus: overrideProps.keyTransparencyStatus ?? 'idle', + isKeyTransparencyEnabled: overrideProps.isKeyTransparencyEnabled ?? true, + checkKeyTransparency: action('check-key-transparency'), + onClose: overrideProps.onClose ?? action('onClose'), }); export default { title: 'Components/SafetyNumberViewer', } satisfies Meta; -export function SafetyNumber(): React.JSX.Element { - return ; +export function SafetyNumber({ + onClose, +}: Partial): React.JSX.Element { + return ; } export function SafetyNumberNotVerified(): React.JSX.Element { @@ -102,6 +106,99 @@ export function SafetyNumberNotVerified(): React.JSX.Element { ); } +export function SafetyNumberKeyTransparencyRunning(): React.JSX.Element { + return ( + + ); +} + +export function SafetyNumberKeyTransparencyOk(): React.JSX.Element { + return ( + + ); +} + +export function SafetyNumberKeyTransparencyFail(): React.JSX.Element { + return ( + + ); +} + +export function SafetyNumberKeyTransparencyUnavailable(): React.JSX.Element { + return ( + + ); +} + +export function SafetyNumberKeyTransparencyAnimation(): React.JSX.Element { + const [status, setStatus] = useState<'idle' | 'running' | 'ok' | 'fail'>( + 'idle' + ); + + useEffect(() => { + let counter = 0; + + const timer = setInterval(() => { + setStatus(oldStatus => { + switch (oldStatus) { + case 'idle': + return 'running'; + case 'running': + return counter === 0 ? 'ok' : 'fail'; + default: + return 'idle'; + } + }); + counter = (counter + 1) % 2; + }, 2000); + + return () => clearInterval(timer); + }, []); + + const props = useMemo(() => { + return createProps({ + contact: { + ...contactWithAllData, + isVerified: false, + }, + }); + }, []); + + return ; +} + export function VerificationDisabled(): React.JSX.Element { return ( void; i18n: LocalizerType; onClose: () => void; safetyNumber: SafetyNumberType | null; toggleVerified: (contact: ConversationType) => void; verificationDisabled: boolean | null; + keyTransparencyStatus: KeyTransparencyStatusType; + isKeyTransparencyEnabled: boolean; + checkKeyTransparency: () => unknown; }; export function SafetyNumberViewer({ contact, - generateSafetyNumber, i18n, onClose, safetyNumber, toggleVerified, verificationDisabled, + keyTransparencyStatus, + isKeyTransparencyEnabled, + checkKeyTransparency, }: PropsType): React.JSX.Element | null { - React.useEffect(() => { - if (!contact) { - return; - } - - generateSafetyNumber(contact); - }, [contact, generateSafetyNumber]); - - // Keyboard navigation - - if (!contact) { - return null; - } + const containerClassName = tw( + 'flex flex-col items-center justify-center gap-4 pb-8' + ); if (!safetyNumber) { return ( -
+
{i18n('icu:cannotGenerateSafetyNumber')}
-
- +
); } - const boldName = ( - - - - ); - const { isVerified } = contact; const verifyButtonText = isVerified ? i18n('icu:SafetyNumberViewer__clearVerification') @@ -76,47 +69,307 @@ export function SafetyNumberViewer({ const numberBlocks = safetyNumber.numberBlocks.join(' '); const safetyNumberCard = ( -
-
- -
- {numberBlocks} -
-
-
- ); - - return ( -
- {safetyNumberCard} - -
- -
- - - +
+ +
+ {numberBlocks}
-
- +
); + + let keyTransparency: JSX.Element | undefined; + if (isKeyTransparencyEnabled) { + keyTransparency = ( + + ); + } + + return ( +
+ {safetyNumberCard} + +
+ +   + + + +
+ + {keyTransparency} +
+ ); +} + +type KeyTransparencyPropsType = Readonly<{ + i18n: LocalizerType; + status: KeyTransparencyStatusType; + checkKeyTransparency: () => unknown; +}>; + +function KeyTransparency({ + i18n, + status, + checkKeyTransparency, +}: KeyTransparencyPropsType): JSX.Element { + const [popup, setPopup] = useState(); + + const resetPopup = useCallback(() => { + setPopup(undefined); + }, []); + + const onKeyTransparencyClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + + switch (status) { + case 'idle': + return checkKeyTransparency(); + case 'running': + return undefined; + case 'unavailable': + case 'ok': + case 'fail': + setPopup(status); + return undefined; + default: + throw missingCaseError(status); + } + }, + [checkKeyTransparency, status] + ); + + let buttonText: string; + let icon: 'key' | 'info' | 'check-circle-fill'; + let disabled = false; + let arrow = false; + let extraIconStyles: TailwindStyles | undefined; + let spinner: JSX.Element | undefined; + switch (status) { + case 'idle': + icon = 'key'; + buttonText = i18n( + 'icu:SafetyNumberViewer__KeyTransparency__button--idle' + ); + break; + case 'running': + disabled = true; + icon = 'info'; + buttonText = i18n( + 'icu:SafetyNumberViewer__KeyTransparency__button--running' + ); + spinner = ( + + ); + break; + case 'ok': + arrow = true; + buttonText = i18n('icu:SafetyNumberViewer__KeyTransparency__button--ok'); + extraIconStyles = tw('text-color-label-affirmative'); + icon = 'check-circle-fill'; + break; + case 'unavailable': + case 'fail': + arrow = true; + buttonText = i18n( + 'icu:SafetyNumberViewer__KeyTransparency__button--fail' + ); + icon = 'info'; + break; + default: + throw missingCaseError(status); + } + + return ( +
+

+ {i18n('icu:SafetyNumberViewer__KeyTransparency__title')} +

+ + + +
+ +   + + + +
+ + {popup && } +
+ ); +} + +type PopupPropsType = Readonly<{ + i18n: LocalizerType; + type: 'ok' | 'fail' | 'unavailable'; + onClose: () => void; +}>; + +function Popup({ i18n, type, onClose }: PopupPropsType): JSX.Element { + let icon: 'check-circle' | 'info'; + let title: string; + let body: string; + + switch (type) { + case 'ok': + icon = 'check-circle'; + title = i18n('icu:SafetyNumberViewer__KeyTransparency__popup--ok__title'); + body = i18n('icu:SafetyNumberViewer__KeyTransparency__popup--ok__body'); + break; + case 'fail': + icon = 'info'; + title = i18n( + 'icu:SafetyNumberViewer__KeyTransparency__popup--fail__title' + ); + body = i18n('icu:SafetyNumberViewer__KeyTransparency__popup--fail__body'); + break; + case 'unavailable': + icon = 'info'; + // Intentionally the same as in 'fail' + title = i18n( + 'icu:SafetyNumberViewer__KeyTransparency__popup--fail__title' + ); + body = i18n( + 'icu:SafetyNumberViewer__KeyTransparency__popup--unavailable__body' + ); + break; + default: + throw missingCaseError(type); + } + + return ( + + + +
+
+ +
+

+ {title} +

+
+ {body} +
+
+
+ + + {i18n('icu:SafetyNumberViewer__KeyTransparency__popup__okay')} + + +
+
+ ); } diff --git a/ts/services/backups/credentials.preload.ts b/ts/services/backups/credentials.preload.ts index e9d0659cc6..edbb7df389 100644 --- a/ts/services/backups/credentials.preload.ts +++ b/ts/services/backups/credentials.preload.ts @@ -15,7 +15,6 @@ import lodashFp from 'lodash/fp.js'; import * as Bytes from '../../Bytes.std.js'; import { createLogger } from '../../logging/log.std.js'; import { strictAssert } from '../../util/assert.std.js'; -import { drop } from '../../util/drop.std.js'; import { isMoreRecentThan, toDayMillis } from '../../util/timestamp.std.js'; import { DAY, @@ -23,7 +22,6 @@ import { HOUR, MINUTE, } from '../../util/durations/index.std.js'; -import { BackOff, FIBONACCI_TIMEOUTS } from '../../util/BackOff.std.js'; import { missingCaseError } from '../../util/missingCaseError.std.js'; import { type BackupCdnReadCredentialType, @@ -32,7 +30,6 @@ import { type BackupSignedPresentationType, BackupCredentialType, } from '../../types/backups.node.js'; -import { toLogFormat } from '../../types/errors.std.js'; import { HTTPError } from '../../types/HTTPError.std.js'; import type { GetBackupCredentialsResponseType, @@ -55,20 +52,28 @@ import { areRemoteBackupsTurnedOn, canAttemptRemoteBackupDownload, } from '../../util/isBackupEnabled.preload.js'; +import { CheckScheduler } from '../../util/CheckScheduler.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; const { throttle } = lodashFp; const log = createLogger('Backup.Credentials'); -const FETCH_INTERVAL = 3 * DAY; - // Credentials should be good for 24 hours, but let's play it safe. const BACKUP_CDN_READ_CREDENTIALS_VALID_DURATION = 12 * HOUR; export class BackupCredentials { #activeFetch: Promise> | undefined; + #scheduler = new CheckScheduler({ + name: 'BackupCredentials', + interval: 3 * DAY, + storageKey: 'backupCombinedCredentialsLastRequestTime', + callback: async () => { + await this.#fetch(); + }, + }); + #cachedCdnReadCredentials: Record< BackupCredentialType, Record @@ -77,8 +82,6 @@ export class BackupCredentials { [BackupCredentialType.Messages]: {}, }; - readonly #fetchBackoff = new BackOff(FIBONACCI_TIMEOUTS); - // Throttle credential clearing to avoid loops public readonly onCdnCredentialError = throttle(5 * MINUTE, () => { log.warn('onCdnCredentialError: clearing cache'); @@ -86,7 +89,7 @@ export class BackupCredentials { }); public start(): void { - this.#scheduleFetch(); + this.#scheduler.start(); } public async getForToday( @@ -201,38 +204,6 @@ export class BackupCredentials { return newCredentials; } - #scheduleFetch(): void { - const lastFetchAt = itemStorage.get( - 'backupCombinedCredentialsLastRequestTime', - 0 - ); - const nextFetchAt = lastFetchAt + FETCH_INTERVAL; - const delay = Math.max(0, nextFetchAt - Date.now()); - - log.info(`scheduling fetch in ${delay}ms`); - setTimeout(() => drop(this.#runPeriodicFetch()), delay); - } - - async #runPeriodicFetch(): Promise { - try { - log.info('running periodic fetch'); - await this.#fetch(); - - const now = Date.now(); - await itemStorage.put('backupCombinedCredentialsLastRequestTime', now); - - this.#fetchBackoff.reset(); - this.#scheduleFetch(); - } catch (error) { - const delay = this.#fetchBackoff.getAndIncrement(); - log.error( - 'periodic fetch failed with ' + - `error: ${toLogFormat(error)}, retrying in ${delay}ms` - ); - setTimeout(() => this.#scheduleFetch(), delay); - } - } - async #fetch(): Promise> { if (this.#activeFetch) { return this.#activeFetch; diff --git a/ts/services/keyTransparency.preload.ts b/ts/services/keyTransparency.preload.ts new file mode 100644 index 0000000000..cfb7580fa1 --- /dev/null +++ b/ts/services/keyTransparency.preload.ts @@ -0,0 +1,282 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + ErrorCode, + LibSignalErrorBase, + PublicKey, + usernames, +} from '@signalapp/libsignal-client'; +import type { + Request, + E164Info, +} from '@signalapp/libsignal-client/dist/net/KeyTransparency.js'; +import { MonitorMode } from '@signalapp/libsignal-client/dist/net/KeyTransparency.js'; + +import { + keyTransparencySearch, + keyTransparencyMonitor, +} from '../textsecure/WebAPI.preload.js'; +import { signalProtocolStore } from '../SignalProtocolStore.preload.js'; +import { itemStorage } from '../textsecure/Storage.preload.js'; +import { fromAciObject } from '../types/ServiceId.std.js'; +import { toLogFormat } from '../types/errors.std.js'; +import { toAciObject } from '../util/ServiceId.node.js'; +import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff.std.js'; +import { sleep } from '../util/sleep.std.js'; +import { SECOND, MINUTE, WEEK } from '../util/durations/constants.std.js'; +import { CheckScheduler } from '../util/CheckScheduler.preload.js'; +import { strictAssert } from '../util/assert.std.js'; +import { isFeaturedEnabledNoRedux } from '../util/isFeatureEnabled.dom.js'; +import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability.std.js'; +import * as Bytes from '../Bytes.std.js'; +import { createLogger } from '../logging/log.std.js'; + +const log = createLogger('KeyTransparency'); + +// Longer timeouts because request size is large (5 second minimum) +const KEY_TRANSPARENCY_TIMEOUTS = FIBONACCI_TIMEOUTS.slice(3); + +const KNOWN_IDENTIFIER_CHANGE_DELAY = 5 * MINUTE; + +export function isKeyTransparencyAvailable(): boolean { + return isFeaturedEnabledNoRedux({ + betaKey: 'desktop.keyTransparency.beta', + prodKey: 'desktop.keyTransparency.prod', + }); +} + +export class KeyTransparency { + #isRunning = false; + #scheduler = new CheckScheduler({ + name: 'KeyTransparency', + interval: WEEK, + storageKey: 'lastKeyTransparencySelfCheck', + backOffTimeouts: KEY_TRANSPARENCY_TIMEOUTS, + + callback: async () => { + try { + await this.selfCheck(); + } catch { + // Ignore exceptions + } + }, + }); + + public start(): void { + strictAssert(!this.#isRunning, 'Already running'); + + this.#isRunning = true; + this.#scheduler.start(); + } + + public async onKnownIdentifierChange(): Promise { + await this.#scheduler.delayBy(KNOWN_IDENTIFIER_CHANGE_DELAY); + } + + public async onRegistrationDone(): Promise { + await this.#scheduler.runAt(Date.now() + KNOWN_IDENTIFIER_CHANGE_DELAY); + } + + public async check( + conversationId: string, + abortSignal?: AbortSignal + ): Promise { + if (!isKeyTransparencyAvailable()) { + log.warn('not running, feature disabled'); + throw new Error('Not available'); + } + + const convo = window.ConversationController.get(conversationId); + strictAssert(convo != null, `Conversation ${conversationId} not found`); + + const aci = convo.getAci(); + strictAssert(aci != null, `Conversation ${conversationId} has no ACI`); + + const identityKey = await signalProtocolStore.loadIdentityKey(aci); + strictAssert( + identityKey != null, + `Conversation ${conversationId} has no identity key` + ); + + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + + let e164Info: E164Info | undefined; + + convo.deriveAccessKeyIfNeeded(); + const e164 = convo.get('e164'); + const accessKey = convo.get('accessKey'); + if (e164 != null && accessKey != null) { + e164Info = { + e164, + unidentifiedAccessKey: Bytes.fromBase64(accessKey), + }; + } + + await this.#verify( + { + aciInfo: { + aci: toAciObject(aci), + identityKey: PublicKey.deserialize(identityKey), + }, + e164Info, + }, + abortSignal + ); + + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + + const selfHealth = itemStorage.get('keyTransparencySelfHealth'); + if (selfHealth == null) { + await this.selfCheck(abortSignal); + } else { + strictAssert(selfHealth === 'ok', 'Self KT check failed'); + } + + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + } + + async selfCheck(abortSignal?: AbortSignal): Promise { + if (!isKeyTransparencyAvailable()) { + log.info('not running, feature disabled'); + return; + } + + const ourAci = itemStorage.user.getAci(); + if (ourAci == null) { + log.info('not running, no aci'); + return; + } + + const keyPair = signalProtocolStore.getIdentityKeyPair(ourAci); + if (keyPair == null) { + log.error('not running, no identity key pair'); + return; + } + + log.info('running self check'); + + const me = window.ConversationController.getOurConversationOrThrow(); + + let e164Info: E164Info | undefined; + if ( + itemStorage.get('phoneNumberDiscoverability') === + PhoneNumberDiscoverability.Discoverable + ) { + const ourE164 = itemStorage.user.getNumber(); + strictAssert(ourE164 != null, 'missing our e164'); + + me.deriveAccessKeyIfNeeded(); + const ourAccessKey = me.get('accessKey'); + strictAssert(ourAccessKey != null, 'missing our access key'); + + e164Info = { + e164: ourE164, + unidentifiedAccessKey: Bytes.fromBase64(ourAccessKey), + }; + } + + let usernameHash: Uint8Array | undefined; + + const username = me.get('username'); + if (username != null) { + usernameHash = usernames.hash(username); + } + + try { + await this.#verify( + { + aciInfo: { + aci: toAciObject(ourAci), + identityKey: keyPair.publicKey, + }, + e164Info, + usernameHash, + }, + abortSignal + ); + + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + + await itemStorage.put('keyTransparencySelfHealth', 'ok'); + log.info('self check success'); + } catch (error) { + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + + log.warn('failed to check our own records', toLogFormat(error)); + await itemStorage.put('keyTransparencySelfHealth', 'fail'); + + window.reduxActions.globalModals.showKeyTransparencyErrorDialog(); + + throw error; + } + } + + async #verify( + request: Request, + abortSignal?: AbortSignal, + backOff = new BackOff(KEY_TRANSPARENCY_TIMEOUTS) + ): Promise { + try { + const existing = await signalProtocolStore.getKTAccountData( + request.aciInfo.aci + ); + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + const aciString = fromAciObject(request.aciInfo.aci); + if (existing == null) { + log.info('search', aciString); + await keyTransparencySearch(request, abortSignal); + } else { + const mode = itemStorage.user.isOurServiceId(aciString) + ? MonitorMode.Self + : MonitorMode.Other; + log.info('monitor', aciString); + await keyTransparencyMonitor(request, mode, abortSignal); + } + } catch (error) { + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + + if (backOff.isFull() || !(error instanceof LibSignalErrorBase)) { + throw error; + } + + let timeout = backOff.getAndIncrement(); + + if ( + error.is(ErrorCode.ChatServiceInactive) || + error.is(ErrorCode.IoError) + ) { + // Use default timeout + } else if (error.is(ErrorCode.RateLimitedError)) { + timeout = error.retryAfterSecs * SECOND; + } else { + // KeyTransparencyError, KeyTransparencyVerificationFailed, etc + throw error; + } + + await sleep(timeout, abortSignal); + + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + + return this.#verify(request, abortSignal, backOff); + } + } +} + +export const keyTransparency = new KeyTransparency(); diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts index b3683f8a2f..f6ebb57cd4 100644 --- a/ts/services/storageRecordOps.preload.ts +++ b/ts/services/storageRecordOps.preload.ts @@ -131,6 +131,7 @@ import { } from '../types/NotificationProfile-node.node.js'; import { itemStorage } from '../textsecure/Storage.preload.js'; import { onHasStoriesDisabledChange } from '../textsecure/WebAPI.preload.js'; +import { keyTransparency } from './keyTransparency.preload.js'; const { isEqual } = lodash; @@ -1734,10 +1735,19 @@ export async function mergeAccountRecord( const discoverability = unlistedPhoneNumber ? PhoneNumberDiscoverability.NotDiscoverable : PhoneNumberDiscoverability.Discoverable; + + // Key Transparancy parameters for self request change whenever + // discoverability changes. Make sure we don't do self check prematurely + if (discoverability !== itemStorage.get('phoneNumberDiscoverability')) { + drop(keyTransparency.onKnownIdentifierChange()); + } await itemStorage.put('phoneNumberDiscoverability', discoverability); if (profileKey && profileKey.byteLength > 0) { - void ourProfileKeyService.set(profileKey); + // Access key is part of Key Transparency request and changing it must + // delay self monitoring. + drop(keyTransparency.onKnownIdentifierChange()); + drop(ourProfileKeyService.set(profileKey)); } if (pinnedConversations) { @@ -2016,12 +2026,14 @@ export async function mergeAccountRecord( const oldStorageID = conversation.get('storageID'); const oldStorageVersion = conversation.get('storageVersion'); - if ( - itemStorage.get('usernameCorrupted') && - username !== conversation.get('username') - ) { - details.push('clearing username corruption'); - await itemStorage.remove('usernameCorrupted'); + if (username !== conversation.get('username')) { + // Username is part of key transparency self monitor parameters. Make sure + // we delay self-check until the changes fully propagate to the log. + drop(keyTransparency.onKnownIdentifierChange()); + if (itemStorage.get('usernameCorrupted')) { + details.push('clearing username corruption'); + await itemStorage.remove('usernameCorrupted'); + } } conversation.set({ diff --git a/ts/services/usernameIntegrity.preload.ts b/ts/services/usernameIntegrity.preload.ts index 65c8b01b7a..b22981323e 100644 --- a/ts/services/usernameIntegrity.preload.ts +++ b/ts/services/usernameIntegrity.preload.ts @@ -4,18 +4,17 @@ import pTimeout from 'p-timeout'; import { usernames } from '@signalapp/libsignal-client'; -import * as Errors from '../types/errors.std.js'; import { whoami } from '../textsecure/WebAPI.preload.js'; import { isDone as isRegistrationDone } from '../util/registration.preload.js'; import { getConversation } from '../util/getConversation.preload.js'; import { MINUTE, DAY } from '../util/durations/index.std.js'; import { drop } from '../util/drop.std.js'; import { explodePromise } from '../util/explodePromise.std.js'; -import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff.std.js'; import { storageJobQueue } from '../util/JobQueue.std.js'; import { getProfile } from '../util/getProfile.preload.js'; import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode.preload.js'; import { bytesToUuid } from '../util/uuidToBytes.std.js'; +import { CheckScheduler } from '../util/CheckScheduler.preload.js'; import { createLogger } from '../logging/log.std.js'; import * as Bytes from '../Bytes.std.js'; import { runStorageServiceSyncJob } from './storage.preload.js'; @@ -24,13 +23,18 @@ import { itemStorage } from '../textsecure/Storage.preload.js'; const log = createLogger('usernameIntegrity'); -const CHECK_INTERVAL = DAY; - const STORAGE_SERVICE_TIMEOUT = 30 * MINUTE; class UsernameIntegrityService { #isStarted = false; - readonly #backOff = new BackOff(FIBONACCI_TIMEOUTS); + #scheduler = new CheckScheduler({ + name: 'UsernameIntegrityService', + interval: DAY, + storageKey: 'usernameLastIntegrityCheck', + callback: async () => { + await storageJobQueue(() => this.#check()); + }, + }); async start(): Promise { if (this.#isStarted) { @@ -39,36 +43,7 @@ class UsernameIntegrityService { this.#isStarted = true; - this.#scheduleCheck(); - } - - #scheduleCheck(): void { - const lastCheckTimestamp = itemStorage.get('usernameLastIntegrityCheck', 0); - const delay = Math.max(0, lastCheckTimestamp + CHECK_INTERVAL - Date.now()); - if (delay === 0) { - log.info('running the check immediately'); - drop(this.#safeCheck()); - } else { - log.info(`running the check in ${delay}ms`); - setTimeout(() => drop(this.#safeCheck()), delay); - } - } - - async #safeCheck(): Promise { - try { - await storageJobQueue(() => this.#check()); - this.#backOff.reset(); - await itemStorage.put('usernameLastIntegrityCheck', Date.now()); - - this.#scheduleCheck(); - } catch (error) { - const delay = this.#backOff.getAndIncrement(); - log.error( - 'check failed with ' + - `error: ${Errors.toLogFormat(error)} retrying in ${delay}ms` - ); - setTimeout(() => drop(this.#safeCheck()), delay); - } + this.#scheduler.start(); } async #check(): Promise { diff --git a/ts/sql/Client.preload.ts b/ts/sql/Client.preload.ts index 59d1bed6be..ce71232f82 100644 --- a/ts/sql/Client.preload.ts +++ b/ts/sql/Client.preload.ts @@ -457,6 +457,7 @@ const ITEM_SPECS: Partial> = { backupMediaRootKey: ['value'], manifestRecordIkm: ['value'], usernameLink: ['value.entropy', 'value.serverId'], + lastDistinguishedTreeHead: ['value'], }; async function createOrUpdateItem( data: ItemType diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index b380b1c1d3..0a320af72b 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -1021,6 +1021,8 @@ type ReadableInterface = { getAllMegaphones: () => ReadonlyArray; hasMegaphone: (megaphoneId: RemoteMegaphoneId) => boolean; + getKTAccountData: (aci: AciString) => Uint8Array | undefined; + getAllPinnedMessages: () => ReadonlyArray; getPinnedMessagesPreloadDataForConversation: ( conversationId: string @@ -1393,6 +1395,8 @@ type WritableInterface = { snoozeMegaphone: (megaphoneId: RemoteMegaphoneId) => void; internalDeleteAllMegaphones: () => number; + setKTAccountData: (aci: AciString, data: Uint8Array) => void; + appendPinnedMessage: ( pinnedMessagesLimit: number, pinnedMessageParams: PinnedMessageParams diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index c1467cbf76..09ad1ecdf1 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -279,6 +279,10 @@ import { getAllMegaphoneImageLocalPaths, hasMegaphone, } from './server/megaphones.std.js'; +import { + getKTAccountData, + setKTAccountData, +} from './server/keyTransparency.std.js'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer.std.js'; import type { GifType } from '../components/fun/panels/FunPanelGifs.dom.js'; import type { NotificationProfileType } from '../types/NotificationProfile.std.js'; @@ -499,6 +503,8 @@ export const DataReader: ServerReadableInterface = { getAllMegaphones, hasMegaphone, + getKTAccountData, + getAllPinnedMessages, getPinnedMessagesPreloadDataForConversation, getNextExpiringPinnedMessageAcrossConversations, @@ -765,6 +771,8 @@ export const DataWriter: ServerWritableInterface = { snoozeMegaphone, internalDeleteAllMegaphones, + setKTAccountData, + appendPinnedMessage, deletePinnedMessageByMessageId, deleteAllExpiredPinnedMessagesBefore, @@ -8384,6 +8392,7 @@ function removeAll(db: WritableDB): void { DELETE FROM identityKeys; DELETE FROM items; DELETE FROM jobs; + DELETE FROM key_transparency_account_data; DELETE FROM kyberPreKeys; DELETE FROM megaphones; DELETE FROM message_attachments; @@ -8444,6 +8453,7 @@ function removeAllConfiguration(db: WritableDB): void { DELETE FROM groupSendCombinedEndorsement; DELETE FROM groupSendMemberEndorsement; DELETE FROM jobs; + DELETE FROM key_transparency_account_data; DELETE FROM kyberPreKeys; DELETE FROM preKeys; DELETE FROM senderKeys; diff --git a/ts/sql/migrations/1640-key-transparency.std.ts b/ts/sql/migrations/1640-key-transparency.std.ts new file mode 100644 index 0000000000..fcd5c556ff --- /dev/null +++ b/ts/sql/migrations/1640-key-transparency.std.ts @@ -0,0 +1,12 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { WritableDB } from '../Interface.std.js'; + +export default function updateToSchemaVersion1640(db: WritableDB): void { + db.exec(` + CREATE TABLE key_transparency_account_data ( + aci TEXT NOT NULL PRIMARY KEY, + data BLOB NOT NULL + ) STRICT; + `); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 7c75ef85aa..1cfd857521 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -140,6 +140,7 @@ import updateToSchemaVersion1600 from './1600-deduplicate-usernames.std.js'; import updateToSchemaVersion1610 from './1610-has-contacts.std.js'; import updateToSchemaVersion1620 from './1620-sort-bigger-media.std.js'; import updateToSchemaVersion1630 from './1630-message-pin-message-data.std.js'; +import updateToSchemaVersion1640 from './1640-key-transparency.std.js'; import { DataWriter } from '../Server.node.js'; @@ -1640,6 +1641,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1610, update: updateToSchemaVersion1610 }, { version: 1620, update: updateToSchemaVersion1620 }, { version: 1630, update: updateToSchemaVersion1630 }, + { version: 1640, update: updateToSchemaVersion1640 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/keyTransparency.std.ts b/ts/sql/server/keyTransparency.std.ts new file mode 100644 index 0000000000..7cdd81302b --- /dev/null +++ b/ts/sql/server/keyTransparency.std.ts @@ -0,0 +1,31 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { AciString } from '../../types/ServiceId.std.js'; +import type { ReadableDB, WritableDB } from '../Interface.std.js'; +import { sql } from '../util.std.js'; + +export function getKTAccountData( + db: ReadableDB, + aci: AciString +): Uint8Array | undefined { + const [query, params] = sql` + SELECT data + FROM key_transparency_account_data + WHERE aci IS ${aci} + `; + return db.prepare(query, { pluck: true }).get(params); +} + +export function setKTAccountData( + db: WritableDB, + aci: AciString, + data: Uint8Array +): void { + const [query, params] = sql` + INSERT OR REPLACE INTO key_transparency_account_data + (aci, data) + VALUES + (${aci}, ${data}); + `; + db.prepare(query).run(params); +} diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts index 3b3cc0f95d..753ce22901 100644 --- a/ts/state/ducks/globalModals.preload.ts +++ b/ts/state/ducks/globalModals.preload.ts @@ -20,6 +20,7 @@ import type { RecipientsByConversation } from './stories.preload.js'; import type { SafetyNumberChangeSource } from '../../types/SafetyNumberChangeSource.std.js'; import type { StateType as RootStateType } from '../reducer.preload.js'; import * as SingleServePromise from '../../services/singleServePromise.std.js'; +import { isKeyTransparencyAvailable } from '../../services/keyTransparency.preload.js'; import * as Stickers from '../../types/Stickers.preload.js'; import { UsernameOnboardingState } from '../../types/globalModals.std.js'; import { createLogger } from '../../logging/log.std.js'; @@ -65,6 +66,7 @@ import type { SmartDraftGifMessageSendModalProps } from '../smart/DraftGifMessag import { onCriticalIdlePrimaryDeviceModalDismissed } from '../../util/handleServerAlerts.preload.js'; import type { PinMessageDialogData } from '../smart/PinMessageDialog.preload.js'; import type { StateThunk } from '../types.std.js'; +import { itemStorage } from '../../textsecure/Storage.preload.js'; const log = createLogger('globalModals'); @@ -144,6 +146,8 @@ export type GlobalModalsStateType = ReadonlyDeep<{ isSignalConnectionsVisible: boolean; isStoriesSettingsVisible: boolean; isWhatsNewVisible: boolean; + isKeyTransparencyErrorVisible: boolean; + isKeyTransparencyOnboardingVisible: boolean; lowDiskSpaceBackupImportModal: { bytesNeeded: number; } | null; @@ -175,6 +179,14 @@ const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL'; const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL'; const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL'; const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL'; +const HIDE_KEY_TRANSPARENCY_ERROR_DIALOG = + 'globalModals/HIDE_KEY_TRANSPARENCY_ERROR_DIALOG'; +const SHOW_KEY_TRANSPARENCY_ERROR_DIALOG = + 'globalModals/SHOW_KEY_TRANSPARENCY_ERROR_DIALOG'; +const HIDE_KEY_TRANSPARENCY_ONBOARDING_DIALOG = + 'globalModals/HIDE_KEY_TRANSPARENCY_ONBOARDING_DIALOG'; +const SHOW_KEY_TRANSPARENCY_ONBOARDING_DIALOG = + 'globalModals/SHOW_KEY_TRANSPARENCY_ONBOARDING_DIALOG'; const HIDE_SERVICE_ID_NOT_FOUND_MODAL = 'globalModals/HIDE_SERVICE_ID_NOT_FOUND_MODAL'; const SHOW_SERVICE_ID_NOT_FOUND_MODAL = @@ -290,6 +302,22 @@ type ShowWhatsNewModalActionType = ReadonlyDeep<{ type: typeof SHOW_WHATS_NEW_MODAL; }>; +type HideKeyTransparencyErrorDialogActionType = ReadonlyDeep<{ + type: typeof HIDE_KEY_TRANSPARENCY_ERROR_DIALOG; +}>; + +type ShowKeyTransparencyErrorDialogActionType = ReadonlyDeep<{ + type: typeof SHOW_KEY_TRANSPARENCY_ERROR_DIALOG; +}>; + +type HideKeyTransparencyOnboardingDialogActionType = ReadonlyDeep<{ + type: typeof HIDE_KEY_TRANSPARENCY_ONBOARDING_DIALOG; +}>; + +type ShowKeyTransparencyOnboardingDialogActionType = ReadonlyDeep<{ + type: typeof SHOW_KEY_TRANSPARENCY_ONBOARDING_DIALOG; +}>; + type HideUserNotFoundModalActionType = ReadonlyDeep<{ type: typeof HIDE_SERVICE_ID_NOT_FOUND_MODAL; }>; @@ -522,6 +550,8 @@ export type GlobalModalsActionType = ReadonlyDeep< | HideCallQualitySurveyActionType | HideContactModalActionType | HideCriticalIdlePrimaryDeviceModalActionType + | HideKeyTransparencyErrorDialogActionType + | HideKeyTransparencyOnboardingDialogActionType | HideLowDiskSpaceBackupImportModalActionType | HideSendAnywayDialogActiontype | HideStoriesSettingsActionType @@ -538,6 +568,8 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowDebugLogErrorModalActionType | ShowEditHistoryModalActionType | ShowErrorModalActionType + | ShowKeyTransparencyErrorDialogActionType + | ShowKeyTransparencyOnboardingDialogActionType | ShowLowDiskSpaceBackupImportModalActionType | ShowMediaPermissionsModalActionType | ShowSendAnywayDialogActionType @@ -579,11 +611,14 @@ export const actions = { closeStickerPackPreview, closeMediaPermissionsModal, ensureSystemMediaPermissions, + finishKeyTransparencyOnboarding, hideBackfillFailureModal, hideBlockingSafetyNumberChangeDialog, hideCallQualitySurvey, hideContactModal, hideCriticalIdlePrimaryDeviceModal, + hideKeyTransparencyErrorDialog, + hideKeyTransparencyOnboardingDialog, hideLowDiskSpaceBackupImportModal, hideStoriesSettings, hideTapToViewNotAvailableModal, @@ -598,6 +633,8 @@ export const actions = { showEditHistoryModal, showErrorModal, showGV2MigrationDialog, + showKeyTransparencyErrorDialog, + showKeyTransparencyOnboardingDialog, showLowDiskSpaceBackupImportModal, showShareCallLinkViaSignal, showShortcutGuideModal, @@ -714,6 +751,30 @@ function showWhatsNewModal(): ShowWhatsNewModalActionType { }; } +function hideKeyTransparencyErrorDialog(): HideKeyTransparencyErrorDialogActionType { + return { + type: HIDE_KEY_TRANSPARENCY_ERROR_DIALOG, + }; +} + +function showKeyTransparencyErrorDialog(): ShowKeyTransparencyErrorDialogActionType { + return { + type: SHOW_KEY_TRANSPARENCY_ERROR_DIALOG, + }; +} + +function hideKeyTransparencyOnboardingDialog(): HideKeyTransparencyOnboardingDialogActionType { + return { + type: HIDE_KEY_TRANSPARENCY_ONBOARDING_DIALOG, + }; +} + +function showKeyTransparencyOnboardingDialog(): ShowKeyTransparencyOnboardingDialogActionType { + return { + type: SHOW_KEY_TRANSPARENCY_ONBOARDING_DIALOG, + }; +} + function hideUserNotFoundModal(): HideUserNotFoundModalActionType { return { type: HIDE_SERVICE_ID_NOT_FOUND_MODAL, @@ -985,10 +1046,25 @@ function toggleProfileNameWarningModal( function toggleSafetyNumberModal( safetyNumberModalContactId?: string -): ToggleSafetyNumberModalActionType { - return { - type: TOGGLE_SAFETY_NUMBER_MODAL, - payload: safetyNumberModalContactId, +): ThunkAction< + void, + RootStateType, + unknown, + | ShowKeyTransparencyOnboardingDialogActionType + | ToggleSafetyNumberModalActionType +> { + return dispatch => { + if ( + isKeyTransparencyAvailable() && + safetyNumberModalContactId != null && + !itemStorage.get('hasSeenKeyTransparencyOnboarding') + ) { + dispatch(showKeyTransparencyOnboardingDialog()); + } + dispatch({ + type: TOGGLE_SAFETY_NUMBER_MODAL, + payload: safetyNumberModalContactId, + }); }; } @@ -1215,6 +1291,18 @@ export function ensureSystemMediaPermissions( }; } +function finishKeyTransparencyOnboarding(): ThunkAction< + void, + RootStateType, + unknown, + HideKeyTransparencyOnboardingDialogActionType +> { + return async dispatch => { + await itemStorage.put('hasSeenKeyTransparencyOnboarding', true); + dispatch(hideKeyTransparencyOnboardingDialog()); + }; +} + function showCriticalIdlePrimaryDeviceModal(): ShowCriticalIdlePrimaryDeviceModalActionType { return { type: SHOW_CRITICAL_IDLE_PRIMARY_DEVICE_MODAL, @@ -1399,6 +1487,8 @@ export function getEmptyState(): GlobalModalsStateType { isSignalConnectionsVisible: false, isStoriesSettingsVisible: false, isWhatsNewVisible: false, + isKeyTransparencyErrorVisible: false, + isKeyTransparencyOnboardingVisible: false, lowDiskSpaceBackupImportModal: null, usernameOnboardingState: UsernameOnboardingState.NeverShown, messageRequestActionsConfirmationProps: null, @@ -1472,6 +1562,34 @@ export function reducer( }; } + if (action.type === SHOW_KEY_TRANSPARENCY_ERROR_DIALOG) { + return { + ...state, + isKeyTransparencyErrorVisible: true, + }; + } + + if (action.type === HIDE_KEY_TRANSPARENCY_ERROR_DIALOG) { + return { + ...state, + isKeyTransparencyErrorVisible: false, + }; + } + + if (action.type === SHOW_KEY_TRANSPARENCY_ONBOARDING_DIALOG) { + return { + ...state, + isKeyTransparencyOnboardingVisible: true, + }; + } + + if (action.type === HIDE_KEY_TRANSPARENCY_ONBOARDING_DIALOG) { + return { + ...state, + isKeyTransparencyOnboardingVisible: false, + }; + } + if (action.type === HIDE_SERVICE_ID_NOT_FOUND_MODAL) { return { ...state, diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx index a8180ad7ee..32408a964e 100644 --- a/ts/state/smart/GlobalModalContainer.preload.tsx +++ b/ts/state/smart/GlobalModalContainer.preload.tsx @@ -32,6 +32,7 @@ import { SmartConfirmLeaveCallModal } from './ConfirmLeaveCallModal.preload.js'; import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipantModal.preload.js'; import { SmartProfileNameWarningModal } from './ProfileNameWarningModal.preload.js'; import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal.preload.js'; +import { SmartKeyTransparencyErrorDialog } from './KeyTransparencyErrorDialog.preload.js'; import { DebugLogErrorModal } from '../../components/DebugLogErrorModal.dom.js'; import { SmartPlaintextExportWorkflow } from './PlaintextExportWorkflow.preload.js'; import { SmartLocalBackupExportWorkflow } from './LocalBackupExportWorkflow.preload.js'; @@ -93,6 +94,10 @@ function renderForwardMessagesModal(): React.JSX.Element { return ; } +function renderKeyTransparencyErrorDialog(): React.JSX.Element { + return ; +} + function renderMessageRequestActionsConfirmation(): React.JSX.Element { return ; } @@ -171,6 +176,8 @@ export const SmartGlobalModalContainer = memo( isShortcutGuideModalVisible, isSignalConnectionsVisible, isStoriesSettingsVisible, + isKeyTransparencyErrorVisible, + isKeyTransparencyOnboardingVisible, isWhatsNewVisible, usernameOnboardingState, safetyNumberChangedBlockingData, @@ -190,6 +197,8 @@ export const SmartGlobalModalContainer = memo( hideUserNotFoundModal, hideWhatsNewModal, hideBackfillFailureModal, + hideKeyTransparencyOnboardingDialog, + finishKeyTransparencyOnboarding, toggleSignalConnectionsModal, } = useGlobalModalActions(); @@ -289,9 +298,15 @@ export const SmartGlobalModalContainer = memo( hideBackfillFailureModal={hideBackfillFailureModal} hideUserNotFoundModal={hideUserNotFoundModal} hideWhatsNewModal={hideWhatsNewModal} + hideKeyTransparencyOnboardingDialog={ + hideKeyTransparencyOnboardingDialog + } + finishKeyTransparencyOnboarding={finishKeyTransparencyOnboarding} hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal} i18n={i18n} isAboutContactModalVisible={aboutContactModalContactId != null} + isKeyTransparencyErrorVisible={isKeyTransparencyErrorVisible} + isKeyTransparencyOnboardingVisible={isKeyTransparencyOnboardingVisible} isProfileNameWarningModalVisible={isProfileNameWarningModalVisible} isShortcutGuideModalVisible={isShortcutGuideModalVisible} isSignalConnectionsVisible={isSignalConnectionsVisible} @@ -313,6 +328,7 @@ export const SmartGlobalModalContainer = memo( renderDeleteMessagesModal={renderDeleteMessagesModal} renderDraftGifMessageSendModal={renderDraftGifMessageSendModal} renderForwardMessagesModal={renderForwardMessagesModal} + renderKeyTransparencyErrorDialog={renderKeyTransparencyErrorDialog} renderMessageRequestActionsConfirmation={ renderMessageRequestActionsConfirmation } diff --git a/ts/state/smart/KeyTransparencyErrorDialog.preload.tsx b/ts/state/smart/KeyTransparencyErrorDialog.preload.tsx new file mode 100644 index 0000000000..e42dd7ff32 --- /dev/null +++ b/ts/state/smart/KeyTransparencyErrorDialog.preload.tsx @@ -0,0 +1,105 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { memo, useCallback, useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { ipcRenderer } from 'electron'; +import lodash from 'lodash'; +import { KeyTransparencyErrorDialog } from '../../components/KeyTransparencyErrorDialog.dom.js'; +import { createSupportUrl } from '../../util/createSupportUrl.std.js'; +import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser.dom.js'; +import { drop } from '../../util/drop.std.js'; +import { useGlobalModalActions } from '../ducks/globalModals.preload.js'; +import { getIntl } from '../selectors/user.std.js'; + +const { noop } = lodash; + +export const SmartKeyTransparencyErrorDialog = memo( + function SmartKeyTransparencyErrorDialog(): React.JSX.Element | null { + const i18n = useSelector(getIntl); + const { hideKeyTransparencyErrorDialog } = useGlobalModalActions(); + const [request, setRequest] = useState< + | undefined + | Readonly<{ + shareDebugLog: boolean; + }> + >(); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + hideKeyTransparencyErrorDialog(); + } + }, + [hideKeyTransparencyErrorDialog] + ); + + const handleSubmit = useCallback((shareDebugLog: boolean) => { + setRequest({ shareDebugLog }); + }, []); + + useEffect(() => { + if (request === undefined) { + return noop; + } + + let canceled = false; + + drop( + (async () => { + const query: Record = { + kt: '', + }; + + if (request.shareDebugLog) { + try { + const logData = await ipcRenderer.invoke('fetch-log'); + const logs: string = await ipcRenderer.invoke( + 'DebugLogs.getLogs', + logData, + window.navigator.userAgent + ); + if (canceled) { + return; + } + query.debugLog = await ipcRenderer.invoke( + 'DebugLogs.upload', + logs + ); + if (canceled) { + return; + } + } catch { + // Ignore + } + } + + const supportURL = createSupportUrl({ + locale: window.SignalContext.getI18nLocale(), + query, + }); + + openLinkInWebBrowser(supportURL); + + setRequest(undefined); + hideKeyTransparencyErrorDialog(); + })() + ); + + return () => { + canceled = true; + }; + }, [request, hideKeyTransparencyErrorDialog]); + + return ( + window.IPC.showDebugLog({ mode: 'close' })} + onSubmit={handleSubmit} + isSubmitting={request !== undefined} + /> + ); + } +); diff --git a/ts/state/smart/SafetyNumberModal.preload.tsx b/ts/state/smart/SafetyNumberModal.preload.tsx index a2eb824b69..929eb253a4 100644 --- a/ts/state/smart/SafetyNumberModal.preload.tsx +++ b/ts/state/smart/SafetyNumberModal.preload.tsx @@ -3,37 +3,33 @@ import React, { memo } from 'react'; import { useSelector } from 'react-redux'; import { SafetyNumberModal } from '../../components/SafetyNumberModal.dom.js'; -import { getContactSafetyNumberSelector } from '../selectors/safetyNumber.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; import { getIntl } from '../selectors/user.std.js'; -import { useSafetyNumberActions } from '../ducks/safetyNumber.preload.js'; import { useGlobalModalActions } from '../ducks/globalModals.preload.js'; +import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog.dom.js'; +import { SmartSafetyNumberViewer } from './SafetyNumberViewer.preload.js'; export type SmartSafetyNumberModalProps = { contactID: string; }; +function renderSafetyNumberViewer(props: SafetyNumberProps): JSX.Element { + return ; +} + export const SmartSafetyNumberModal = memo(function SmartSafetyNumberModal({ contactID, }: SmartSafetyNumberModalProps) { const i18n = useSelector(getIntl); const conversationSelector = useSelector(getConversationSelector); const contact = conversationSelector(contactID); - const contactSafetyNumberSelector = useSelector( - getContactSafetyNumberSelector - ); - const contactSafetyNumber = contactSafetyNumberSelector(contactID); - const { generateSafetyNumber, toggleVerified } = useSafetyNumberActions(); const { toggleSafetyNumberModal } = useGlobalModalActions(); return ( ); }); diff --git a/ts/state/smart/SafetyNumberViewer.preload.tsx b/ts/state/smart/SafetyNumberViewer.preload.tsx index a151c8ce81..181146c272 100644 --- a/ts/state/smart/SafetyNumberViewer.preload.tsx +++ b/ts/state/smart/SafetyNumberViewer.preload.tsx @@ -1,14 +1,22 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo } from 'react'; +import React, { memo, useState, useEffect, useCallback } from 'react'; +import lodash from 'lodash'; import { useSelector } from 'react-redux'; import { SafetyNumberViewer } from '../../components/SafetyNumberViewer.dom.js'; import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog.dom.js'; import { getContactSafetyNumberSelector } from '../selectors/safetyNumber.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; import { getIntl } from '../selectors/user.std.js'; +import { getItems } from '../selectors/items.dom.js'; import { useSafetyNumberActions } from '../ducks/safetyNumber.preload.js'; +import { keyTransparency } from '../../services/keyTransparency.preload.js'; +import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; +import { drop } from '../../util/drop.std.js'; +import type { KeyTransparencyStatusType } from '../../types/KeyTransparency.d.ts'; + +const { noop } = lodash; export const SmartSafetyNumberViewer = memo(function SmartSafetyNumberViewer({ contactID, @@ -21,17 +29,72 @@ export const SmartSafetyNumberViewer = memo(function SmartSafetyNumberViewer({ const safetyNumberContact = contactSafetyNumberSelector(contactID); const conversationSelector = useSelector(getConversationSelector); const contact = conversationSelector(contactID); + const items = useSelector(getItems); + + const version = window.SignalContext.getVersion(); + const isKeyTransparencyEnabled = isFeaturedEnabledSelector({ + betaKey: 'desktop.keyTransparency.beta', + prodKey: 'desktop.keyTransparency.prod', + currentVersion: version, + remoteConfig: items.remoteConfig, + }); + + const isKeyTransparencyAvailable = contact.e164 != null; + + const [keyTransparencyStatus, setKeyTransparencyStatus] = + useState( + isKeyTransparencyAvailable ? 'idle' : 'unavailable' + ); const { generateSafetyNumber, toggleVerified } = useSafetyNumberActions(); + useEffect(() => { + generateSafetyNumber(contact); + }, [contact, generateSafetyNumber]); + + useEffect(() => { + if (keyTransparencyStatus !== 'running') { + return noop; + } + + const abortController = new AbortController(); + + drop( + (async () => { + try { + await keyTransparency.check(contactID, abortController.signal); + setKeyTransparencyStatus('ok'); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + setKeyTransparencyStatus('fail'); + } + })() + ); + + return () => { + abortController.abort(); + }; + }, [contactID, keyTransparencyStatus]); + + const checkKeyTransparency = useCallback(async () => { + if (!isKeyTransparencyEnabled || !isKeyTransparencyAvailable) { + return; + } + setKeyTransparencyStatus('running'); + }, [isKeyTransparencyEnabled, isKeyTransparencyAvailable]); + return ( ); diff --git a/ts/textsecure/SocketManager.preload.ts b/ts/textsecure/SocketManager.preload.ts index 343a4cdd39..3cda0d5dc0 100644 --- a/ts/textsecure/SocketManager.preload.ts +++ b/ts/textsecure/SocketManager.preload.ts @@ -15,10 +15,7 @@ import EventListener from 'node:events'; import type { IncomingMessage } from 'node:http'; import { setTimeout as sleep } from 'node:timers/promises'; -import type { - UnauthMessagesService, - UnauthUsernamesService, -} from '@signalapp/libsignal-client/dist/net'; +import type { UnauthenticatedChatConnection } from '@signalapp/libsignal-client/dist/net/Chat.js'; import { strictAssert } from '../util/assert.std.js'; import { explodePromise } from '../util/explodePromise.std.js'; @@ -434,9 +431,7 @@ export class SocketManager extends EventListener { }).getResult(); } - public async getUnauthenticatedLibsignalApi(): Promise< - UnauthUsernamesService & UnauthMessagesService - > { + public async getUnauthenticatedLibsignalApi(): Promise { const resource = await this.#getUnauthenticatedResource(); return resource.libsignalWebsocket; } diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index 4f799adb95..6a5b5ac0be 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -24,6 +24,10 @@ import type { } from '@signalapp/libsignal-client'; import { AccountAttributes } from '@signalapp/libsignal-client/dist/net.js'; import { GroupSendFullToken } from '@signalapp/libsignal-client/zkgroup.js'; +import type { + Request as KTRequest, + MonitorMode as KTMonitorMode, +} from '@signalapp/libsignal-client/dist/net/KeyTransparency.js'; import { assertDev, strictAssert } from '../util/assert.std.js'; import * as durations from '../util/durations/index.std.js'; @@ -120,6 +124,7 @@ import { type RemoteMegaphoneId, } from '../types/Megaphone.std.js'; import { bindRemoteConfigToLibsignalNet } from '../LibsignalNetRemoteConfig.preload.js'; +import { KeyTransparencyStore } from '../LibSignalStores.preload.js'; const { escapeRegExp, isNumber, throttle } = lodash; @@ -2480,6 +2485,44 @@ export async function getAccountForUsername({ return aci ? fromAciObject(aci) : null; } +export async function keyTransparencySearch( + request: KTRequest, + abortSignal?: AbortSignal +): Promise { + return _retry(async () => { + const chat = await socketManager.getUnauthenticatedLibsignalApi(); + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + const kt = chat.keyTransparencyClient(); + const store = new KeyTransparencyStore(); + return kt.search(request, store, { abortSignal }); + }); +} + +export async function keyTransparencyMonitor( + request: KTRequest, + mode: KTMonitorMode, + abortSignal?: AbortSignal +): Promise { + return _retry(async () => { + const chat = await socketManager.getUnauthenticatedLibsignalApi(); + if (abortSignal?.aborted) { + throw new Error('Aborted'); + } + const kt = chat.keyTransparencyClient(); + const store = new KeyTransparencyStore(); + return kt.monitor( + { + ...request, + mode, + }, + store, + { abortSignal } + ); + }); +} + export async function putProfile( jsonData: ProfileRequestDataType ): Promise { diff --git a/ts/types/KeyTransparency.d.ts b/ts/types/KeyTransparency.d.ts new file mode 100644 index 0000000000..8276e5d0b4 --- /dev/null +++ b/ts/types/KeyTransparency.d.ts @@ -0,0 +1,9 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type KeyTransparencyStatusType = + | 'idle' + | 'running' + | 'ok' + | 'fail' + | 'unavailable'; diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index ec32d30a31..99bb2c3cf1 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -92,6 +92,7 @@ export type StorageAccessType = { hasCompletedSafetyNumberOnboarding: boolean; hasSeenGroupStoryEducationSheet: boolean; hasSeenNotificationProfileOnboarding: boolean; + hasSeenKeyTransparencyOnboarding: boolean; hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; storyViewReceiptsEnabled: boolean | undefined; @@ -271,6 +272,11 @@ export type StorageAccessType = { avatarsHaveBeenMigrated: boolean; + // Key Transparency + lastDistinguishedTreeHead: Uint8Array; + keyTransparencySelfHealth: 'ok' | 'fail'; + lastKeyTransparencySelfCheck: number; + // Test-only // Not used UI, stored as is when imported from backup during tests defaultWallpaperPhotoPointer: Uint8Array; diff --git a/ts/types/support.std.ts b/ts/types/support.std.ts index 4fd97f09c5..748f5bdc14 100644 --- a/ts/types/support.std.ts +++ b/ts/types/support.std.ts @@ -9,5 +9,7 @@ export const LINK_SIGNAL_DESKTOP = 'https://support.signal.org/hc/articles/360007320451#desktop_multiple_device'; export const SAFETY_NUMBER_URL = 'https://support.signal.org/hc/articles/360007060632'; +export const KEY_TRANSPARENCY_URL = + 'https://support.signal.org/hc/articles/10223569377562'; export const SYNCING_MESSAGES_SECURITY_URL = 'https://support.signal.org/hc/articles/360007320391'; diff --git a/ts/util/CheckScheduler.preload.ts b/ts/util/CheckScheduler.preload.ts new file mode 100644 index 0000000000..facb83fa48 --- /dev/null +++ b/ts/util/CheckScheduler.preload.ts @@ -0,0 +1,104 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConditionalKeys } from 'type-fest'; + +import type { StorageAccessType } from '../types/Storage.d.ts'; +import { toLogFormat } from '../types/errors.std.js'; +import { itemStorage } from '../textsecure/Storage.preload.js'; +import { createLogger } from '../logging/log.std.js'; +import { LongTimeout } from './timeout.std.js'; +import { drop } from './drop.std.js'; +import { BackOff, FIBONACCI_TIMEOUTS } from './BackOff.std.js'; + +const log = createLogger('CheckScheduler'); + +export type CheckSchedulerOptionsType = Readonly<{ + name: string; + interval: number; + storageKey: ConditionalKeys; + backOffTimeouts?: ReadonlyArray; + callback: () => Promise; +}>; + +export class CheckScheduler { + #options: CheckSchedulerOptionsType; + #log: ReturnType; + #timer: LongTimeout | undefined; + #isRunning = false; + + constructor(options: CheckSchedulerOptionsType) { + this.#options = options; + this.#log = log.child(options.name); + } + + start(): void { + if (this.#isRunning) { + throw new Error( + `CheckScheduler(${this.#options.name}) is already running` + ); + } + this.#isRunning = true; + this.#scheduleCheck(); + } + + async runAt(timestamp: number): Promise { + await itemStorage.put( + this.#options.storageKey, + timestamp - this.#options.interval + ); + + this.#scheduleCheck(); + } + + async delayBy(ms: number): Promise { + const earliestCheck = Date.now() + ms; + + const lastCheckTimestamp = itemStorage.get(this.#options.storageKey, 0); + await itemStorage.put( + this.#options.storageKey, + Math.max(lastCheckTimestamp, earliestCheck - this.#options.interval) + ); + + this.#scheduleCheck(); + } + + #scheduleCheck(): void { + const now = Date.now(); + const lastCheckTimestamp = itemStorage.get( + this.#options.storageKey, + // Gracefully rollout when polling initially + now - this.#options.interval * Math.random() + ); + const delay = Math.max( + 0, + lastCheckTimestamp + this.#options.interval - now + ); + this.#timer?.clear(); + this.#timer = undefined; + if (delay === 0) { + this.#log.info('running the check immediately'); + drop(this.#safeCheck()); + } else { + this.#log.info(`running the check in ${delay}ms`); + this.#timer = new LongTimeout(() => drop(this.#safeCheck()), delay); + } + } + + async #safeCheck( + backOff = new BackOff(this.#options.backOffTimeouts ?? FIBONACCI_TIMEOUTS) + ): Promise { + try { + await this.#options.callback(); + await itemStorage.put(this.#options.storageKey, Date.now()); + + this.#scheduleCheck(); + } catch (error) { + this.#log.error('check failed with error', toLogFormat(error)); + this.#timer = new LongTimeout( + () => drop(this.#safeCheck()), + backOff.getAndIncrement() + ); + } + } +}