;
+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 = (
-
- );
-
- 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()
+ );
+ }
+ }
+}