diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6c3c7f8e5..a1549510c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: repository: 'signalapp/Signal-Message-Backup-Tests' - ref: '50336e18dc0171551b1684ff81690b2f515dd217' + ref: 'ec8104da8fed163dc47bd5ec5d048786eccc0dfb' path: 'backup-integration-tests' - run: xvfb-run --auto-servernum pnpm run test-electron diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7f961daa4f..a7baaf22aa 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2394,6 +2394,18 @@ "messageformat": "Cancel", "description": "Preferences > Edit Chat Folder Page > Cancel Button" }, + "icu:Preferences__PrivacyPage__KeyTransparency__Label": { + "messageformat": "Automatic key verification", + "description": "Preferences > Privacy Page > Key Transparency Checkbox > Label" + }, + "icu:Preferences__PrivacyPage__KeyTransparency__Description": { + "messageformat": "When enabled, Signal will attempt to automatically verify the encryption of 1:1 chats.", + "description": "Preferences > Privacy Page > Key Transparency Checkbox > Description" + }, + "icu:Preferences__PrivacyPage__KeyTransparency__LearnMore": { + "messageformat": "Learn More", + "description": "Preferences > Privacy Page > Key Transparency Checkbox > Learn More" + }, "icu:initialSync": { "messageformat": "Syncing contacts and groups", "description": "Shown during initial link while contacts and groups are being pulled from mobile device" diff --git a/protos/Backups.proto b/protos/Backups.proto index 324000d525..c28e018104 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -136,6 +136,7 @@ message AccountData { AppTheme appTheme = 28; // If unset, treat the same as "Unknown" case CallsUseLessDataSetting callsUseLessDataSetting = 29; // If unset, treat the same as "Unknown" case bool allowSealedSenderFromAnyone = 30; + bool allowAutomaticKeyVerification = 31; } message SubscriberData { @@ -182,6 +183,8 @@ message AccountData { AndroidSpecificSettings androidSpecificSettings = 12; string bioText = 13; string bioEmoji = 14; + // Opaque blob containing key transparency data for the account + optional bytes keyTransparencyData = 15; } message Recipient { @@ -270,6 +273,8 @@ message Contact { string systemFamilyName = 19; string systemNickname = 20; optional AvatarColor avatarColor = 21; + // Opaque blob containing key transparency data for the contact + optional bytes keyTransparencyData = 22; } message Group { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 3dc7dc5209..dc0ba48f61 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -301,6 +301,7 @@ message AccountRecord { optional AvatarColor avatarColor = 42; NotificationProfileManualOverride notificationProfileManualOverride = 44; bool notificationProfileSyncDisabled = 45; + bool automaticKeyVerificationDisabled = 46; } message StoryDistributionListRecord { diff --git a/ts/components/KeyTransparencyErrorDialog.dom.tsx b/ts/components/KeyTransparencyErrorDialog.dom.tsx index 1625ba8568..cdf486500c 100644 --- a/ts/components/KeyTransparencyErrorDialog.dom.tsx +++ b/ts/components/KeyTransparencyErrorDialog.dom.tsx @@ -33,17 +33,10 @@ export function KeyTransparencyErrorDialog( return ( - - - {i18n('icu:KeyTransparencyErrorDialog__Title')} - - - +

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

-
+
void; + onHasKeyTransparencyDisabledChanged: SelectChangeHandlerType; onHasStoriesDisabledChanged: SelectChangeHandlerType; onHideMenuBarChange: CheckboxChangeHandlerType; onIncomingCallNotificationsChange: CheckboxChangeHandlerType; @@ -415,6 +418,7 @@ export function Preferences({ hasFailedStorySends, hasHideMenuBar, hasIncomingCallNotifications, + hasKeyTransparencyDisabled, hasLinkPreviews, hasMediaCameraPermissions, hasMediaPermissions, @@ -462,6 +466,7 @@ export function Preferences({ onContentProtectionChange, onCountMutedConversationsChange, onEmojiSkinToneDefaultChange, + onHasKeyTransparencyDisabledChanged, onHasStoriesDisabledChanged, onHideMenuBarChange, onIncomingCallNotificationsChange, @@ -1815,6 +1820,36 @@ export function Preferences({
+ + + onHasKeyTransparencyDisabledChanged(!hasKeyTransparencyDisabled) + } + /> +
+
+ {i18n( + 'icu:Preferences__PrivacyPage__KeyTransparency__Description' + )} +   + + + +
+
+
- + {spinner ?? ( )} diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 567a6a55cb..65ad5a0258 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -2140,7 +2140,7 @@ export class ConversationModel { this.set({ e164: e164 || undefined }); // This user changed their phone number - if (oldValue && e164 && this.get('sharingPhoneNumber')) { + if (oldValue && e164) { void this.addChangeNumberNotification(oldValue, e164); } diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index 4d224ac311..d1efa9587c 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -204,6 +204,11 @@ const REPORTING_THRESHOLD = SECOND; const MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH = 128 * KIBIBYTE; const BACKUP_QUOTE_BODY_LIMIT = 2048; +type ToRecipientOptionsType = Readonly<{ + identityKeysById: ReadonlyMap; + keyTransparencyData: Uint8Array | undefined; +}>; + type GetRecipientIdOptionsType = | Readonly<{ serviceId: ServiceIdString; @@ -407,15 +412,28 @@ export class BackupExportStream extends Readable { }) ); + const ktAcis = new Set(await DataReader.getAllKTAcis()); + const skippedConversationIds = new Set(); for (const { attributes } of window.ConversationController.getAll()) { const recipientId = this.#getRecipientId(attributes); - const recipient = this.#toRecipient( - recipientId, - attributes, - identityKeysById - ); + let keyTransparencyData: Uint8Array | undefined; + if ( + isDirectConversation(attributes) && + isAciString(attributes.serviceId) && + ktAcis.has(attributes.serviceId) + ) { + // eslint-disable-next-line no-await-in-loop + keyTransparencyData = await DataReader.getKTAccountData( + attributes.serviceId + ); + } + + const recipient = this.#toRecipient(recipientId, attributes, { + identityKeysById, + keyTransparencyData, + }); if (recipient === undefined) { skippedConversationIds.add(attributes.id); // Can't be backed up. @@ -977,6 +995,10 @@ export class BackupExportStream extends Readable { const themeSetting = await window.Events.getThemeSetting(); const appTheme = toAppTheme(themeSetting); + const keyTransparencyData = await DataReader.getKTAccountData( + me.getCheckedAci('Backup export: key transparency data') + ); + return { profileKey: itemStorage.get('profileKey'), username: me.get('username') || null, @@ -1006,6 +1028,7 @@ export class BackupExportStream extends Readable { svrPin: itemStorage.get('svrPin'), bioText: me.get('about'), bioEmoji: me.get('aboutEmoji'), + keyTransparencyData, // Test only values ...(isTestOrMockEnvironment() ? { @@ -1029,6 +1052,9 @@ export class BackupExportStream extends Readable { hasSetMyStoriesPrivacy: itemStorage.get('hasSetMyStoriesPrivacy'), hasViewedOnboardingStory: itemStorage.get('hasViewedOnboardingStory'), storiesDisabled: itemStorage.get('hasStoriesDisabled'), + allowAutomaticKeyVerification: !itemStorage.get( + 'hasKeyTransparencyDisabled' + ), storyViewReceiptsEnabled: itemStorage.get('storyViewReceiptsEnabled'), hasCompletedUsernameOnboarding: itemStorage.get( 'hasCompletedUsernameOnboarding' @@ -1155,7 +1181,7 @@ export class BackupExportStream extends Readable { ConversationAttributesType, 'id' | 'version' | 'expireTimerVersion' >, - identityKeysById?: ReadonlyMap + options?: ToRecipientOptionsType ): Backups.IRecipient | undefined { const res: Backups.IRecipient = { id: recipientId, @@ -1200,8 +1226,8 @@ export class BackupExportStream extends Readable { } let identityKey: IdentityKeyType | undefined; - if (identityKeysById != null && convo.serviceId != null) { - identityKey = identityKeysById.get(convo.serviceId); + if (options != null && convo.serviceId != null) { + identityKey = options.identityKeysById.get(convo.serviceId); } const { nicknameGivenName, nicknameFamilyName, note } = convo; @@ -1253,6 +1279,7 @@ export class BackupExportStream extends Readable { hideStory: convo.hideStory === true, identityKey: identityKey?.publicKey || null, avatarColor: toAvatarColor(convo.color), + keyTransparencyData: options?.keyTransparencyData, // Integer values match so we can use it as is identityState: identityKey?.verified ?? 0, diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index 1759aa2a85..6b5e787bf7 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -752,6 +752,7 @@ export class BackupImportStream extends Writable { androidSpecificSettings, bioText, bioEmoji, + keyTransparencyData, }: Backups.IAccountData): Promise { strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData'); const me = { @@ -793,6 +794,15 @@ export class BackupImportStream extends Writable { if (bioEmoji != null) { me.aboutEmoji = bioEmoji; } + if (Bytes.isNotEmpty(keyTransparencyData)) { + const ourAci = this.#ourConversation?.serviceId; + strictAssert( + isAciString(ourAci), + 'Must have our aci for Key Transparency data' + ); + + await DataWriter.setKTAccountData(ourAci, keyTransparencyData); + } if (avatarUrlPath != null) { await itemStorage.put('avatarUrl', avatarUrlPath); } @@ -865,6 +875,10 @@ export class BackupImportStream extends Writable { 'hasStoriesDisabled', accountSettings?.storiesDisabled === true ); + await itemStorage.put( + 'hasKeyTransparencyDisabled', + accountSettings?.allowAutomaticKeyVerification !== true + ); // an undefined value for storyViewReceiptsEnabled is semantically different from // false: it causes us to fallback to `read-receipt-setting` @@ -1132,6 +1146,15 @@ export class BackupImportStream extends Writable { } } + if (Bytes.isNotEmpty(contact.keyTransparencyData)) { + strictAssert( + isAciString(serviceId), + 'Must have contact aci for Key Transparency data' + ); + + await DataWriter.setKTAccountData(serviceId, contact.keyTransparencyData); + } + return attrs; } diff --git a/ts/services/keyTransparency.preload.ts b/ts/services/keyTransparency.preload.ts index cfb7580fa1..056f8a36f5 100644 --- a/ts/services/keyTransparency.preload.ts +++ b/ts/services/keyTransparency.preload.ts @@ -12,6 +12,7 @@ import type { E164Info, } from '@signalapp/libsignal-client/dist/net/KeyTransparency.js'; import { MonitorMode } from '@signalapp/libsignal-client/dist/net/KeyTransparency.js'; +import pTimeout from 'p-timeout'; import { keyTransparencySearch, @@ -22,15 +23,20 @@ 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 { TaskDeduplicator } from '../util/TaskDeduplicator.std.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 { SECOND, MINUTE, DAY, 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 { explodePromise } from '../util/explodePromise.std.js'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability.std.js'; import * as Bytes from '../Bytes.std.js'; import { createLogger } from '../logging/log.std.js'; +import { isEnabled } from '../RemoteConfig.dom.js'; +import { DataWriter } from '../sql/Client.preload.js'; +import { runStorageServiceSyncJob } from './storage.preload.js'; const log = createLogger('KeyTransparency'); @@ -39,7 +45,14 @@ const KEY_TRANSPARENCY_TIMEOUTS = FIBONACCI_TIMEOUTS.slice(3); const KNOWN_IDENTIFIER_CHANGE_DELAY = 5 * MINUTE; +const INTERMITTENT_ERROR_RETRY_DELAY = 1 * DAY; + +const STORAGE_SERVICE_TIMEOUT = 1 * MINUTE; + export function isKeyTransparencyAvailable(): boolean { + if (itemStorage.get('hasKeyTransparencyDisabled')) { + return false; + } return isFeaturedEnabledNoRedux({ betaKey: 'desktop.keyTransparency.beta', prodKey: 'desktop.keyTransparency.prod', @@ -63,6 +76,20 @@ export class KeyTransparency { }, }); + #selfCheckDedup = new TaskDeduplicator( + 'KeyTransparency.selfCheck', + abortSignal => this.#selfCheck(abortSignal) + ); + + public async disable(): Promise { + await Promise.all([ + DataWriter.removeAllKTAccountData(), + itemStorage.remove('lastDistinguishedTreeHead'), + itemStorage.remove('keyTransparencySelfHealth'), + itemStorage.remove('lastKeyTransparencySelfCheck'), + ]); + } + public start(): void { strictAssert(!this.#isRunning, 'Already running'); @@ -143,6 +170,10 @@ export class KeyTransparency { } async selfCheck(abortSignal?: AbortSignal): Promise { + return this.#selfCheckDedup.run(abortSignal); + } + + async #selfCheck(abortSignal: AbortSignal): Promise { if (!isKeyTransparencyAvailable()) { log.info('not running, feature disabled'); return; @@ -185,10 +216,24 @@ export class KeyTransparency { let usernameHash: Uint8Array | undefined; const username = me.get('username'); - if (username != null) { + if (username != null && !itemStorage.get('usernameCorrupted')) { usernameHash = usernames.hash(username); } + if (itemStorage.get('keyTransparencySelfHealth') === 'intermittent') { + runStorageServiceSyncJob({ reason: 'keyTransparency' }); + + const { promise: once, resolve } = explodePromise(); + + // This makes sure that we are both on empty websocket queue and fully + // up-to-date on storage service. + window.Whisper.events.once('storageService:syncComplete', () => + resolve() + ); + + await pTimeout(once, STORAGE_SERVICE_TIMEOUT); + } + try { await this.#verify( { @@ -213,10 +258,55 @@ export class KeyTransparency { throw new Error('Aborted'); } - log.warn('failed to check our own records', toLogFormat(error)); - await itemStorage.put('keyTransparencySelfHealth', 'fail'); + const oldResult = itemStorage.get('keyTransparencySelfHealth'); + let newResult: 'fail' | 'intermittent' | undefined; - window.reduxActions.globalModals.showKeyTransparencyErrorDialog(); + if (error instanceof LibSignalErrorBase) { + if (error.is(ErrorCode.KeyTransparencyVerificationFailed)) { + if (oldResult === 'intermittent' || oldResult === 'fail') { + newResult = 'fail'; + } else { + newResult = 'intermittent'; + } + } else if ( + error.is(ErrorCode.KeyTransparencyError) || + error.is(ErrorCode.ChatServiceInactive) || + error.is(ErrorCode.IoError) || + error.is(ErrorCode.RateLimitedError) + ) { + if (oldResult === 'intermittent' || oldResult === 'fail') { + // Keep failed state until successful retry + newResult = oldResult; + } else { + // Update status to "unknown" + newResult = undefined; + } + } else { + // Unknown error + newResult = 'fail'; + } + } + + log.warn( + 'failed to check our own records', + toLogFormat(error), + 'changing state to', + newResult + ); + + await itemStorage.put('keyTransparencySelfHealth', newResult); + if ( + (oldResult !== 'fail' || isEnabled('desktop.internalUser')) && + newResult === 'fail' + ) { + window.reduxActions.globalModals.showKeyTransparencyErrorDialog(); + } + + if (newResult === 'intermittent') { + await this.#scheduler.runAt( + Date.now() + INTERMITTENT_ERROR_RETRY_DELAY + ); + } throw error; } @@ -268,6 +358,10 @@ export class KeyTransparency { throw error; } + log.warn( + `retriable error error=${toLogFormat(error)} retrying in ` + + `${timeout}ms` + ); await sleep(timeout, abortSignal); if (abortSignal?.aborted) { diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts index f6ebb57cd4..72d10fdd3d 100644 --- a/ts/services/storageRecordOps.preload.ts +++ b/ts/services/storageRecordOps.preload.ts @@ -591,6 +591,12 @@ export function toAccountRecord( hasSeenGroupStoryEducationSheet; } + const hasKeyTransparencyDisabled = itemStorage.get( + 'hasKeyTransparencyDisabled' + ); + accountRecord.automaticKeyVerificationDisabled = + hasKeyTransparencyDisabled === true; + const hasStoriesDisabled = itemStorage.get('hasStoriesDisabled'); accountRecord.storiesDisabled = hasStoriesDisabled === true; @@ -1652,6 +1658,7 @@ export async function mergeAccountRecord( usernameLink, notificationProfileManualOverride, notificationProfileSyncDisabled, + automaticKeyVerificationDisabled, } = accountRecord; const conversation = @@ -1922,6 +1929,18 @@ export async function mergeAccountRecord( hasCompletedUsernameOnboardingBool ); } + { + const hasKeyTransparencyDisabled = Boolean( + automaticKeyVerificationDisabled + ); + await itemStorage.put( + 'hasKeyTransparencyDisabled', + hasKeyTransparencyDisabled + ); + if (hasKeyTransparencyDisabled) { + await keyTransparency.disable(); + } + } { const hasStoriesDisabled = Boolean(storiesDisabled); await itemStorage.put('hasStoriesDisabled', hasStoriesDisabled); diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 300f422cda..643bfb29d4 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -1022,6 +1022,7 @@ type ReadableInterface = { getAllMegaphoneIds: () => ReadonlyArray; hasMegaphone: (megaphoneId: RemoteMegaphoneId) => boolean; + getAllKTAcis: () => ReadonlyArray; getKTAccountData: (aci: AciString) => Uint8Array | undefined; getAllPinnedMessages: () => ReadonlyArray; @@ -1396,6 +1397,7 @@ type WritableInterface = { snoozeMegaphone: (megaphoneId: RemoteMegaphoneId) => void; internalDeleteAllMegaphones: () => number; + removeAllKTAccountData: () => void; setKTAccountData: (aci: AciString, data: Uint8Array) => void; appendPinnedMessage: ( diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 70a0d1ca27..800ee77218 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -281,8 +281,10 @@ import { hasMegaphone, } from './server/megaphones.std.js'; import { + getAllKTAcis, getKTAccountData, setKTAccountData, + removeAllKTAccountData, } 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'; @@ -505,6 +507,7 @@ export const DataReader: ServerReadableInterface = { getAllMegaphoneIds, hasMegaphone, + getAllKTAcis, getKTAccountData, getAllPinnedMessages, @@ -774,6 +777,7 @@ export const DataWriter: ServerWritableInterface = { internalDeleteAllMegaphones, setKTAccountData, + removeAllKTAccountData, appendPinnedMessage, deletePinnedMessageByMessageId, diff --git a/ts/sql/server/keyTransparency.std.ts b/ts/sql/server/keyTransparency.std.ts index 7cdd81302b..8ba8813cac 100644 --- a/ts/sql/server/keyTransparency.std.ts +++ b/ts/sql/server/keyTransparency.std.ts @@ -4,6 +4,14 @@ import type { AciString } from '../../types/ServiceId.std.js'; import type { ReadableDB, WritableDB } from '../Interface.std.js'; import { sql } from '../util.std.js'; +export function getAllKTAcis(db: ReadableDB): Array { + const [query, params] = sql` + SELECT aci + FROM key_transparency_account_data + `; + return db.prepare(query, { pluck: true }).all(params); +} + export function getKTAccountData( db: ReadableDB, aci: AciString @@ -29,3 +37,9 @@ export function setKTAccountData( `; db.prepare(query).run(params); } + +export function removeAllKTAccountData(db: WritableDB): void { + db.exec(` + DELETE FROM key_transparency_account_data; + `); +} diff --git a/ts/state/selectors/items.dom.ts b/ts/state/selectors/items.dom.ts index 82c1e37246..5a0fe5e154 100644 --- a/ts/state/selectors/items.dom.ts +++ b/ts/state/selectors/items.dom.ts @@ -144,6 +144,11 @@ export const getStoriesEnabled = createSelector( (state: ItemsStateType): boolean => !state.hasStoriesDisabled ); +export const getKeyTransparencyEnabled = createSelector( + getItems, + (state: ItemsStateType): boolean => !state.hasKeyTransparencyDisabled +); + export const getDefaultConversationColor = createSelector( getItems, ( diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index c5d89646eb..0294cc8610 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -57,6 +57,7 @@ import { DurationInSeconds } from '../../util/durations/duration-in-seconds.std. import { PhoneNumberDiscoverability } from '../../util/phoneNumberDiscoverability.std.js'; import { PhoneNumberSharingMode } from '../../types/PhoneNumberSharingMode.std.js'; import { writeProfile } from '../../services/writeProfile.preload.js'; +import { keyTransparency } from '../../services/keyTransparency.preload.js'; import { getConversation } from '../../util/getConversation.preload.js'; import { waitForEvent } from '../../shims/events.dom.js'; import { DAY, MINUTE } from '../../util/durations/index.std.js'; @@ -687,6 +688,14 @@ export function SmartPreferences(): React.JSX.Element | null { } } ); + const [hasKeyTransparencyDisabled, onHasKeyTransparencyDisabledChanged] = + createItemsAccess('hasKeyTransparencyDisabled', false, async value => { + const account = window.ConversationController.getOurConversationOrThrow(); + account.captureChange('hasKeyTransparencyDisabled'); + if (value) { + await keyTransparency.disable(); + } + }); const [hasTextFormatting, onTextFormattingChange] = createItemsAccess( 'textFormatting', true @@ -836,6 +845,7 @@ export function SmartPreferences(): React.JSX.Element | null { hasFailedStorySends={hasFailedStorySends} hasHideMenuBar={hasHideMenuBar} hasIncomingCallNotifications={hasIncomingCallNotifications} + hasKeyTransparencyDisabled={hasKeyTransparencyDisabled} hasLinkPreviews={hasLinkPreviews} hasMediaCameraPermissions={hasMediaCameraPermissions} hasMediaPermissions={hasMediaPermissions} @@ -885,6 +895,9 @@ export function SmartPreferences(): React.JSX.Element | null { onContentProtectionChange={onContentProtectionChange} onCountMutedConversationsChange={onCountMutedConversationsChange} onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} + onHasKeyTransparencyDisabledChanged={ + onHasKeyTransparencyDisabledChanged + } onHasStoriesDisabledChanged={onHasStoriesDisabledChanged} onHideMenuBarChange={onHideMenuBarChange} onIncomingCallNotificationsChange={onIncomingCallNotificationsChange} diff --git a/ts/state/smart/SafetyNumberViewer.preload.tsx b/ts/state/smart/SafetyNumberViewer.preload.tsx index 89fa633450..9464cd55a1 100644 --- a/ts/state/smart/SafetyNumberViewer.preload.tsx +++ b/ts/state/smart/SafetyNumberViewer.preload.tsx @@ -9,7 +9,7 @@ import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialo import { getContactSafetyNumberSelector } from '../selectors/safetyNumber.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; import { getIntl, getVersion } from '../selectors/user.std.js'; -import { getItems } from '../selectors/items.dom.js'; +import { getItems, getKeyTransparencyEnabled } 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'; @@ -28,16 +28,20 @@ export const SmartSafetyNumberViewer = memo(function SmartSafetyNumberViewer({ ); const safetyNumberContact = contactSafetyNumberSelector(contactID); const conversationSelector = useSelector(getConversationSelector); + const hasKeyTransparencyEnabled = useSelector(getKeyTransparencyEnabled); const contact = conversationSelector(contactID); const items = useSelector(getItems); const version = useSelector(getVersion); - const isKeyTransparencyEnabled = isFeaturedEnabledSelector({ - betaKey: 'desktop.keyTransparency.beta', - prodKey: 'desktop.keyTransparency.prod', - currentVersion: version, - remoteConfig: items.remoteConfig, - }); + + const isKeyTransparencyEnabled = + hasKeyTransparencyEnabled && + isFeaturedEnabledSelector({ + betaKey: 'desktop.keyTransparency.beta', + prodKey: 'desktop.keyTransparency.prod', + currentVersion: version, + remoteConfig: items.remoteConfig, + }); const isKeyTransparencyAvailable = contact.e164 != null; diff --git a/ts/test-node/util/TaskDeduplicator_test.std.ts b/ts/test-node/util/TaskDeduplicator_test.std.ts new file mode 100644 index 0000000000..04e28f90e2 --- /dev/null +++ b/ts/test-node/util/TaskDeduplicator_test.std.ts @@ -0,0 +1,106 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { TaskDeduplicator } from '../../util/TaskDeduplicator.std.js'; +import { explodePromise } from '../../util/explodePromise.std.js'; +import { drop } from '../../util/drop.std.js'; + +describe('TaskDeduplicator', () => { + it('should run a task', async () => { + const t = new TaskDeduplicator('test', () => Promise.resolve()); + await t.run(); + }); + + it('should not run two tasks concurrently', async () => { + let count = 0; + const { promise, resolve } = explodePromise(); + const t = new TaskDeduplicator('test', () => { + count += 1; + return promise; + }); + + const p1 = t.run(); + const p2 = t.run(); + assert.strictEqual(count, 1); + + resolve(); + await Promise.all([p1, p2]); + + assert.strictEqual(count, 1); + }); + + it('should abort a task', async () => { + function hangUntilAbort(abortSignal: AbortSignal) { + const { promise, reject } = explodePromise(); + abortSignal.addEventListener('abort', () => { + reject(new Error('Aborted')); + }); + return promise; + } + + const t = new TaskDeduplicator('test', hangUntilAbort); + + const controller = new AbortController(); + const p = assert.isRejected(t.run(controller.signal), 'Aborted'); + + controller.abort(); + await p; + }); + + it('should not abort both tasks, if another one is running', async () => { + const { promise, resolve, reject } = explodePromise(); + + function hangUntilAbort(abortSignal: AbortSignal) { + abortSignal.addEventListener('abort', () => { + reject(new Error('Aborted')); + }); + return promise; + } + + const t = new TaskDeduplicator('test', hangUntilAbort); + + const c1 = new AbortController(); + const p1 = assert.isRejected(t.run(c1.signal), 'Aborted'); + + const c2 = new AbortController(); + const p2 = t.run(c2.signal); + + // Abort only the first call + c1.abort(); + await p1; + + // Second call should resolve normally + resolve(); + await p2; + }); + + it('should cleanup after aborting both tasks', async () => { + let count = 0; + const { promise, reject } = explodePromise(); + + function hangUntilAbort(abortSignal: AbortSignal) { + count += 1; + abortSignal.addEventListener('abort', () => { + reject(new Error('Aborted')); + }); + return promise; + } + + const t = new TaskDeduplicator('test', hangUntilAbort); + + const controller = new AbortController(); + const p1 = assert.isRejected(t.run(controller.signal), 'Aborted'); + + const p2 = assert.isRejected(t.run(controller.signal), 'Aborted'); + + // Abort both calls + controller.abort(); + await p1; + await p2; + assert.strictEqual(count, 1); + + drop(t.run()); + assert.strictEqual(count, 2); + }); +}); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 99bb2c3cf1..756df119e0 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -95,6 +95,7 @@ export type StorageAccessType = { hasSeenKeyTransparencyOnboarding: boolean; hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; + hasKeyTransparencyDisabled: boolean; storyViewReceiptsEnabled: boolean | undefined; identityKeyMap: IdentityKeyMap; lastAttemptedToRefreshProfilesAt: number; @@ -274,7 +275,13 @@ export type StorageAccessType = { // Key Transparency lastDistinguishedTreeHead: Uint8Array; - keyTransparencySelfHealth: 'ok' | 'fail'; + // Meaning of values: + // + // - undefined - status unknown or uninitialized + // - 'ok' - last check passed + // - 'intermittent' - last check failed, but we haven't retried yet + // - 'fail' - last check failed after retry + keyTransparencySelfHealth: undefined | 'ok' | 'intermittent' | 'fail'; lastKeyTransparencySelfCheck: number; // Test-only diff --git a/ts/util/CheckScheduler.preload.ts b/ts/util/CheckScheduler.preload.ts index facb83fa48..95e1dde53d 100644 --- a/ts/util/CheckScheduler.preload.ts +++ b/ts/util/CheckScheduler.preload.ts @@ -9,6 +9,7 @@ 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 { strictAssert } from './assert.std.js'; import { BackOff, FIBONACCI_TIMEOUTS } from './BackOff.std.js'; const log = createLogger('CheckScheduler'); @@ -43,24 +44,36 @@ export class CheckScheduler { } async runAt(timestamp: number): Promise { + log.info(`Updating next run to ${new Date(timestamp).toISOString()}`); + await itemStorage.put( this.#options.storageKey, timestamp - this.#options.interval ); - this.#scheduleCheck(); + // Restart the timer if running + if (this.#timer != null) { + 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) + const newTimestamp = Math.max( + lastCheckTimestamp, + earliestCheck - this.#options.interval ); - this.#scheduleCheck(); + log.info(`Delaying next run until ${new Date(newTimestamp).toISOString()}`); + + await itemStorage.put(this.#options.storageKey, newTimestamp); + + // Restart the timer if running + if (this.#timer != null) { + this.#scheduleCheck(); + } } #scheduleCheck(): void { @@ -70,18 +83,30 @@ export class CheckScheduler { // Gracefully rollout when polling initially now - this.#options.interval * Math.random() ); - const delay = Math.max( - 0, - lastCheckTimestamp + this.#options.interval - now - ); - this.#timer?.clear(); + const targetTimestamp = lastCheckTimestamp + this.#options.interval; + const delay = Math.max(0, targetTimestamp - now); + if (this.#timer != null) { + this.#timer.clear(); + this.#log.info('clearing previous timer'); + } 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); + this.#log.info( + 'running the check at', + new Date(targetTimestamp).toISOString() + ); + const timer = new LongTimeout(() => { + strictAssert( + this.#timer === timer, + 'Timer was canceled without clearing first' + ); + this.#timer = undefined; + drop(this.#safeCheck()); + }, delay); + this.#timer = timer; } } @@ -89,8 +114,13 @@ export class CheckScheduler { backOff = new BackOff(this.#options.backOffTimeouts ?? FIBONACCI_TIMEOUTS) ): Promise { try { + const oldTimestamp = itemStorage.get(this.#options.storageKey); await this.#options.callback(); - await itemStorage.put(this.#options.storageKey, Date.now()); + + // Allow callback to update the next scheduled time + if (oldTimestamp === itemStorage.get(this.#options.storageKey)) { + await itemStorage.put(this.#options.storageKey, Date.now()); + } this.#scheduleCheck(); } catch (error) { diff --git a/ts/util/TaskDeduplicator.std.ts b/ts/util/TaskDeduplicator.std.ts new file mode 100644 index 0000000000..afb9e130c8 --- /dev/null +++ b/ts/util/TaskDeduplicator.std.ts @@ -0,0 +1,69 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { strictAssert } from './assert.std.js'; +import { explodePromise } from './explodePromise.std.js'; + +// A wrapper class around a task that should not run concurrently. +// `TaskDeduplicator` takes `abortSignal` for each `run` and thus lets you +// cancel both individual invocations and the deduplicated actual task function +// run. +// +// Usage: +// +// const task = new TaskDeduplicator('myTask', async (abortSignal) => { +// await sleep(1000, abortSignal); +// }); +// +// await task.run(); +// await task.run(otherAbortSignal); +// +export class TaskDeduplicator { + #task: (abortSignal: AbortSignal) => Promise; + #current: Promise | undefined; + #remaining = 0; + #abortController: AbortController | undefined; + + constructor( + public readonly name: string, + task: (abortSignal: AbortSignal) => Promise + ) { + this.#task = task; + } + + async run(abortSignal?: AbortSignal): Promise { + const { promise: localAbort, reject: localReject } = + explodePromise(); + + if (abortSignal != null) { + this.#remaining += 1; + abortSignal.addEventListener('abort', () => { + this.#remaining -= 1; + if (this.#remaining === 0) { + strictAssert( + this.#abortController != null, + `TaskDeduplicator(${this.name}): missing abort controller` + ); + this.#abortController.abort(); + } + + localReject(new Error('Aborted')); + }); + } + + if (this.#current != null) { + return Promise.race([this.#current, localAbort]); + } + + this.#abortController = new AbortController(); + + try { + this.#current = this.#task(this.#abortController.signal); + return await Promise.race([this.#current, localAbort]); + } finally { + this.#current = undefined; + this.#abortController = undefined; + this.#remaining = 0; + } + } +}