From 0d02bd994d9609e7084e7844185622e64b2d9012 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Wed, 2 Sep 2026 11:20:05 +0200 Subject: [PATCH 1/2] fix: recover after key-package exhaustion [WPB-27396] --- apps/webapp/src/script/main/app.ts | 103 +++++++++++++- .../src/script/mls/MLSConversations.test.ts | 93 +++++++++++++ .../webapp/src/script/mls/MLSConversations.ts | 126 +++++++++++++++++- .../conversation/ConversationRepository.ts | 9 ++ .../mls/mlsService/mlsService.test.ts | 120 ++++++++++++++++- .../mls/mlsService/mlsService.ts | 58 +++++++- libraries/core/src/storage/coreDb.ts | 9 +- 7 files changed, 512 insertions(+), 6 deletions(-) diff --git a/apps/webapp/src/script/main/app.ts b/apps/webapp/src/script/main/app.ts index f8f8833d398..cd84ca7ddc0 100644 --- a/apps/webapp/src/script/main/app.ts +++ b/apps/webapp/src/script/main/app.ts @@ -20,6 +20,7 @@ // Polyfill for "tsyringe" dependency injection import type {WallClock} from '@enormora/wall-clock/wall-clock'; +import {isNonEmptyArray} from '@sindresorhus/is'; import {Context} from '@wireapp/api-client/lib/auth'; import {ClientClassification, ClientType} from '@wireapp/api-client/lib/client/'; import {FEATURE_KEY, FEATURE_STATUS, FeatureList} from '@wireapp/api-client/lib/team'; @@ -31,6 +32,7 @@ import 'core-js/full/reflect'; import pWaitFor from 'p-wait-for'; import platform from 'platform'; import {pdfjs} from 'react-pdf'; +import {task} from 'true-myth'; import {container} from 'tsyringe'; import {Runtime} from '@wireapp/commons'; @@ -98,6 +100,7 @@ import {DebugUtil} from 'Util/debugUtil'; import {Environment} from 'Util/environment'; import {type Translate} from 'Util/localizerUtil'; import {getLogger, Logger} from 'Util/logger'; +import {matchQualifiedIds} from 'Util/qualifiedId'; import {durationFrom, formatCoarseDuration, TIME_IN_MILLIS} from 'Util/timeUtil'; import {AppInitializationStep, checkIndexedDb, InitializationEventLogger} from 'Util/util'; @@ -121,7 +124,7 @@ import { startNewVersionPolling, } from '../lifecycle/newVersionHandler'; import {scheduleApiVersionUpdate, updateApiVersion} from '../lifecycle/updateRemoteConfigs'; -import {initialiseSelfAndTeamConversations, initMLSGroupConversations} from '../mls'; +import {initialiseSelfAndTeamConversations, initMLSGroupConversations, recoverMLSConversationsInBatches} from '../mls'; import {joinConversationsAfterMigrationFinalisation} from '../mls/MLSMigration/migrationFinaliser'; import type {ApplicationObservability} from '../observability/applicationObservability'; import type {ApplicationStartupReport} from '../observability/applicationStartupReport'; @@ -190,6 +193,7 @@ export class App { debug?: DebugUtil; util?: {debug: DebugUtil}; private newVersionPollingCleanup: (() => void) | undefined; + private mlsConversationRecoveryCleanup: (() => void) | undefined; static get CONFIG() { return { @@ -788,6 +792,12 @@ export class App { // resume the notification queue now that we're fully initialized this.core.resumeNotificationQueue(); + this.initializeMLSConversationRecovery({ + conversationRepository, + eventRepository, + fireAndForgetInvoker, + }); + return selfUser; } catch (error: unknown) { return reportStartupFailure(error, { @@ -808,6 +818,92 @@ export class App { } } + private initializeMLSConversationRecovery({ + conversationRepository, + eventRepository, + fireAndForgetInvoker, + }: { + conversationRepository: ConversationRepository; + eventRepository: EventRepository; + fireAndForgetInvoker: FireAndForgetInvoker; + }): void { + const mlsService = this.core.service?.mls; + if (!mlsService) { + return; + } + + let recoveryInProgress = false; + const isApplicationActive = () => document.visibilityState === 'visible'; + const isNotificationSyncLive = () => + eventRepository.notificationHandlingState() === NOTIFICATION_HANDLING_STATE.WEB_SOCKET; + + const recoverConversations = async (): Promise => { + // Atomic check-and-set to prevent concurrent recovery attempts + if (recoveryInProgress || !isApplicationActive() || !isNotificationSyncLive()) { + return; + } + + recoveryInProgress = true; + const recoveryTask = await task.tryOrElse( + error => error, + async () => { + if (!(await mlsService.prepareMLSConversationRecovery(this.core.clientId))) { + return; + } + + const allConversations = await conversationRepository.refreshConversationsForMLSRecovery(); + const pendingIds = await mlsService.getPendingRecoveryConversationIds(); + + // Only process conversations that haven't been recovered yet + const conversations = isNonEmptyArray(pendingIds) + ? allConversations.filter(conv => pendingIds.some(pending => matchQualifiedIds(pending, conv.qualifiedId))) + : allConversations; + + const result = await recoverMLSConversationsInBatches({ + conversations, + conversationRepository, + core: this.core, + isActive: isApplicationActive, + mlsService, + }); + + if (result.completed && isApplicationActive()) { + await mlsService.completeMLSConversationRecovery(); + this.logger.info('Completed MLS conversation recovery', { + recoveredConversationCount: result.recoveredConversationCount, + }); + } + }, + ); + recoveryInProgress = false; + + if (recoveryTask.isErr) { + this.logger.error('Failed to run MLS conversation recovery', recoveryTask.error); + } + }; + + const triggerRecovery = () => fireAndForgetInvoker.fireAndForget(recoverConversations); + const handleVisibilityChange = () => { + if (isApplicationActive()) { + triggerRecovery(); + } + }; + + mlsService.on(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED, triggerRecovery); + window.addEventListener('focus', triggerRecovery); + window.addEventListener('online', triggerRecovery); + document.addEventListener('visibilitychange', handleVisibilityChange); + + this.mlsConversationRecoveryCleanup = () => { + mlsService.off(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED, triggerRecovery); + window.removeEventListener('focus', triggerRecovery); + window.removeEventListener('online', triggerRecovery); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + + triggerRecovery(); + } + private _appInitFailure(error: BaseError) { const {message, type} = error; let logMessage = `Could not initialize app version '${Environment.version(false)}'`; @@ -925,6 +1021,11 @@ export class App { this.newVersionPollingCleanup = undefined; } + if (this.mlsConversationRecoveryCleanup !== undefined) { + this.mlsConversationRecoveryCleanup(); + this.mlsConversationRecoveryCleanup = undefined; + } + if (selfUser.isActivatedAccount()) { this.repository.storage.terminate('window.onunload'); } else { diff --git a/apps/webapp/src/script/mls/MLSConversations.test.ts b/apps/webapp/src/script/mls/MLSConversations.test.ts index 9bbcaac0efc..4a7503effce 100644 --- a/apps/webapp/src/script/mls/MLSConversations.test.ts +++ b/apps/webapp/src/script/mls/MLSConversations.test.ts @@ -45,6 +45,7 @@ import { initMLSGroupConversations, initialiseSelfAndTeamConversations, readLocalMLSState, + recoverMLSConversationsInBatches, } from './MLSConversations'; function createMLSConversation(type?: CONVERSATION_TYPE, epoch = 0): MLSConversation { @@ -140,6 +141,98 @@ describe('MLSConversations', () => { }); }); + describe('recoverMLSConversationsInBatches', () => { + it('recovers active MLS and mixed conversations while skipping Proteus, past-member, and established groups', async () => { + const mlsGroup = createMLSConversation(CONVERSATION_TYPE.REGULAR, 1); + const mlsOneToOne = createMLSConversation(CONVERSATION_TYPE.ONE_TO_ONE, 1); + const mixedSelf = new Conversation( + randomUUID(), + '', + CONVERSATION_PROTOCOL.MIXED, + translateForTest, + ) as MLSConversation; + mixedSelf.groupId = `groupid-${randomUUID()}`; + mixedSelf.type(CONVERSATION_TYPE.SELF); + const established = createMLSConversation(CONVERSATION_TYPE.REGULAR, 1); + const pastMember = createMLSConversation(CONVERSATION_TYPE.REGULAR, 1); + pastMember.status(ConversationStatus.PAST_MEMBER); + const proteus = new Conversation(randomUUID(), '', CONVERSATION_PROTOCOL.PROTEUS, translateForTest); + + const conversationRepository = await testFactory.exposeConversationActors(); + const repositoryCore = conversationRepository['core']; + jest + .spyOn(repositoryCore.service!.conversation, 'mlsGroupExistsLocally') + .mockImplementation(async groupId => groupId === established.groupId); + const recoverSpy = jest + .spyOn(conversationRepository, 'safeEnsureConversationExists') + .mockReturnValue(task.resolve(undefined)); + + const result = await recoverMLSConversationsInBatches({ + conversations: [mlsGroup, mlsOneToOne, mixedSelf, established, pastMember, proteus], + conversationRepository, + core: repositoryCore, + isActive: () => true, + batchSize: 2, + }); + + expect(result).toEqual({completed: true, failedConversationCount: 0, recoveredConversationCount: 3}); + expect(recoverSpy).toHaveBeenCalledTimes(3); + expect(recoverSpy).toHaveBeenCalledWith({ + conversationId: mlsOneToOne.qualifiedId, + groupId: mlsOneToOne.groupId, + core: repositoryCore, + }); + expect(recoverSpy).toHaveBeenCalledWith({ + conversationId: mixedSelf.qualifiedId, + groupId: mixedSelf.groupId, + core: repositoryCore, + }); + }); + + it('pauses before the next batch when the application becomes inactive', async () => { + const conversations = createMLSConversations(12, CONVERSATION_TYPE.REGULAR); + const conversationRepository = await testFactory.exposeConversationActors(); + const repositoryCore = conversationRepository['core']; + jest.spyOn(repositoryCore.service!.conversation, 'mlsGroupExistsLocally').mockResolvedValue(false); + const recoverSpy = jest + .spyOn(conversationRepository, 'safeEnsureConversationExists') + .mockReturnValue(task.resolve(undefined)); + const isActive = jest.fn().mockReturnValueOnce(true).mockReturnValue(false); + + const result = await recoverMLSConversationsInBatches({ + conversations, + conversationRepository, + core: repositoryCore, + isActive, + batchSize: 5, + }); + + expect(result).toEqual({completed: false, failedConversationCount: 0, recoveredConversationCount: 5}); + expect(recoverSpy).toHaveBeenCalledTimes(5); + }); + + it('keeps recovery incomplete after an individual failure and continues auditing the batch', async () => { + const conversations = createMLSConversations(3, CONVERSATION_TYPE.REGULAR); + const conversationRepository = await testFactory.exposeConversationActors(); + const repositoryCore = conversationRepository['core']; + jest.spyOn(repositoryCore.service!.conversation, 'mlsGroupExistsLocally').mockResolvedValue(false); + const recoverSpy = jest + .spyOn(conversationRepository, 'safeEnsureConversationExists') + .mockReturnValueOnce(task.reject(new Error('join failed'))) + .mockReturnValue(task.resolve(undefined)); + + const result = await recoverMLSConversationsInBatches({ + conversations, + conversationRepository, + core: repositoryCore, + isActive: () => true, + }); + + expect(result).toEqual({completed: false, failedConversationCount: 1, recoveredConversationCount: 2}); + expect(recoverSpy).toHaveBeenCalledTimes(3); + }); + }); + it('schedules key renewal intervals for all already established mls groups', async () => { const core = new Account(); const nbMLSConversations = 5 + Math.ceil(Math.random() * 10); diff --git a/apps/webapp/src/script/mls/MLSConversations.ts b/apps/webapp/src/script/mls/MLSConversations.ts index edcbc04e5c2..7169cd1fa8d 100644 --- a/apps/webapp/src/script/mls/MLSConversations.ts +++ b/apps/webapp/src/script/mls/MLSConversations.ts @@ -18,7 +18,7 @@ */ import {QualifiedId} from '@wireapp/api-client/lib/user'; -import {Maybe} from 'true-myth'; +import {Maybe, task} from 'true-myth'; import {match, P} from 'ts-pattern'; import {Account, MLSService} from '@wireapp/core'; @@ -38,9 +38,133 @@ import {Conversation} from 'Repositories/entity/Conversation'; import {User} from 'Repositories/entity/User'; import {UserState} from 'Repositories/user/userState'; import {getLogger} from 'Util/logger'; +import {matchQualifiedIds} from 'Util/qualifiedId'; +import {isNonEmptyArray} from '@sindresorhus/is'; const logger = getLogger('Webapp/MLSConversations'); +// Process 10 conversations per batch to balance recovery speed with system +// responsiveness. Larger batches could block the event loop; smaller batches +// increase total recovery time. +const DEFAULT_RECOVERY_BATCH_SIZE = 10; + +export type MLSConversationRecoveryResult = { + completed: boolean; + failedConversationCount: number; + recoveredConversationCount: number; +}; + +/** + * Audits active MLS-capable conversations in bounded batches and rejoins groups + * which are missing from CoreCrypto. A completed result is only returned after + * every eligible conversation was checked successfully. + */ +export async function recoverMLSConversationsInBatches({ + conversations, + conversationRepository, + core, + isActive, + mlsService, + batchSize = DEFAULT_RECOVERY_BATCH_SIZE, +}: { + conversations: Conversation[]; + conversationRepository: ConversationRepository; + core: Account; + isActive: () => boolean; + mlsService?: MLSService; + batchSize?: number; +}): Promise { + const conversationService = core.service?.conversation; + if (!conversationService) { + logger.error('Conversation service is not available for MLS conversation recovery'); + return {completed: false, failedConversationCount: 1, recoveredConversationCount: 0}; + } + + const eligibleConversations = conversations.filter( + (conversation): conversation is MLSCapableConversation => + isMLSCapableConversation(conversation) && !conversation.isSelfUserRemoved(), + ); + const boundedBatchSize = Math.max(1, batchSize); + let failedConversationCount = 0; + let recoveredConversationCount = 0; + + // Initialize pending conversation IDs on first run + if (mlsService && eligibleConversations.length > 0) { + const pendingIds = await mlsService.getPendingRecoveryConversationIds(); + if (!isNonEmptyArray(pendingIds)) { + const allPendingIds = eligibleConversations.map(conv => conv.qualifiedId); + await mlsService.updatePendingRecoveryConversationIds(allPendingIds); + } + } + + for (let offset = 0; offset < eligibleConversations.length; offset += boundedBatchSize) { + if (!isActive()) { + logger.info('Pausing MLS conversation recovery because the application is inactive', { + failedConversationCount, + recoveredConversationCount, + }); + return {completed: false, failedConversationCount, recoveredConversationCount}; + } + + const batch = eligibleConversations.slice(offset, offset + boundedBatchSize); + for (const conversation of batch) { + const localGroupResult = await task.tryOrElse( + error => error, + () => conversationService.mlsGroupExistsLocally(conversation.groupId), + ); + + if (localGroupResult.isErr) { + failedConversationCount++; + logger.error('Failed to check local MLS conversation state during recovery', { + conversationId: conversation.qualifiedId, + error: localGroupResult.error, + }); + continue; + } + + if (localGroupResult.value) { + continue; + } + + const recoveryResult = await conversationRepository.safeEnsureConversationExists({ + conversationId: conversation.qualifiedId, + groupId: conversation.groupId, + core, + }); + + if (recoveryResult.isErr) { + failedConversationCount++; + logger.error('Failed to recover pending MLS conversation', { + conversationId: conversation.qualifiedId, + error: recoveryResult.error, + }); + continue; + } + + recoveredConversationCount++; + + // Remove successfully recovered conversation from pending list + if (mlsService) { + const pendingIds = (await mlsService.getPendingRecoveryConversationIds()) ?? []; + const updatedPending = pendingIds.filter(id => !matchQualifiedIds(id, conversation.qualifiedId)); + await mlsService.updatePendingRecoveryConversationIds(updatedPending); + } + } + + logger.info('Processed MLS conversation recovery batch', { + batchSize: batch.length, + failedConversationCount, + recoveredConversationCount, + }); + } + + return { + completed: failedConversationCount === 0, + failedConversationCount, + recoveredConversationCount, + }; +} + /** * Will initialize all the MLS conversations that the user is member of but that are not yet locally established. * Includes group, channel, and meeting conversations. diff --git a/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts b/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts index 2554db7daf3..485810d4dfb 100644 --- a/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts +++ b/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts @@ -699,6 +699,15 @@ export class ConversationRepository { return this.loadRemoteConversations(remoteConversations, connections, deadConnections); } + /** + * Refreshes the complete conversation list before auditing MLS membership. + * This discovers conversations created while this client had no key packages + * and therefore could not receive their Welcome messages. + */ + public async refreshConversationsForMLSRecovery(): Promise { + return this.loadConversations(this.connectionState.connections(), this.connectionState.deadConnections()); + } + /** * Will try to fetch and load all the missing conversations in memory * @returns all the missing conversations freshly fetched from backend appended to the locally stored conversations diff --git a/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.test.ts b/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.test.ts index fc267effde6..8e1e8657218 100644 --- a/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.test.ts +++ b/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.test.ts @@ -40,7 +40,7 @@ import { } from '@wireapp/core-crypto'; import {CORE_CRYPTO_ERROR_NAMES} from './coreCryptoMlsError'; -import {InitClientOptions, MLSService} from './mlsService'; +import {InitClientOptions, MLSService, MLSServiceEvents} from './mlsService'; import {AddUsersFailure, AddUsersFailureReasons} from '../../../conversation'; import {openDB} from '../../../storage/coreDb'; @@ -555,6 +555,124 @@ describe('MLSService', () => { }); }); + describe('MLS conversation recovery after key-package exhaustion', () => { + it('persists recovery after uploading when the backend count is zero', async () => { + const [mlsService, {apiClient, coreDatabase, transactionContext}] = await createMLSService(); + await coreDatabase.clear('mlsConversationRecovery'); + jest.spyOn(apiClient.api.client, 'getMLSKeyPackageCount').mockResolvedValueOnce(0); + jest.spyOn(transactionContext, 'clientKeypackages').mockResolvedValueOnce([new Uint8Array()]); + jest.spyOn(apiClient.api.client, 'uploadMLSKeyPackages').mockImplementationOnce(async () => { + expect(await mlsService.isMLSConversationRecoveryRequired()).toBe(false); + }); + const emitSpy = jest.spyOn(mlsService, 'emit'); + + await mlsService['verifyRemoteMLSKeyPackagesAmount']('client-1'); + + expect(await mlsService.isMLSConversationRecoveryRequired()).toBe(true); + expect(emitSpy).toHaveBeenCalledWith(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + }); + + it('does not schedule recovery for a normal low-count refill', async () => { + const [mlsService, {apiClient, coreDatabase, transactionContext}] = await createMLSService(); + await coreDatabase.clear('mlsConversationRecovery'); + jest.spyOn(apiClient.api.client, 'getMLSKeyPackageCount').mockResolvedValueOnce(1); + jest.spyOn(transactionContext, 'clientKeypackages').mockResolvedValueOnce([new Uint8Array()]); + jest.spyOn(apiClient.api.client, 'uploadMLSKeyPackages').mockResolvedValueOnce(undefined); + const emitSpy = jest.spyOn(mlsService, 'emit'); + + await mlsService['verifyRemoteMLSKeyPackagesAmount']('client-1'); + + expect(await mlsService.isMLSConversationRecoveryRequired()).toBe(false); + expect(emitSpy).not.toHaveBeenCalledWith(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + }); + + it('does not persist recovery and does not emit when the zero-count refill fails', async () => { + const [mlsService, {apiClient, coreDatabase, transactionContext}] = await createMLSService(); + await coreDatabase.clear('mlsConversationRecovery'); + jest.spyOn(apiClient.api.client, 'getMLSKeyPackageCount').mockResolvedValueOnce(0); + jest.spyOn(transactionContext, 'clientKeypackages').mockResolvedValueOnce([new Uint8Array()]); + jest.spyOn(apiClient.api.client, 'uploadMLSKeyPackages').mockRejectedValueOnce(new Error('upload failed')); + const emitSpy = jest.spyOn(mlsService, 'emit'); + + await expect(mlsService['verifyRemoteMLSKeyPackagesAmount']('client-1')).rejects.toThrow('upload failed'); + + expect(await mlsService.isMLSConversationRecoveryRequired()).toBe(false); + expect(emitSpy).not.toHaveBeenCalledWith(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + }); + + it('does not fail refill when recovery marker persistence fails', async () => { + const [mlsService, {apiClient, coreDatabase, transactionContext}] = await createMLSService(); + await coreDatabase.clear('mlsConversationRecovery'); + jest.spyOn(apiClient.api.client, 'getMLSKeyPackageCount').mockResolvedValueOnce(0); + jest.spyOn(transactionContext, 'clientKeypackages').mockResolvedValueOnce([new Uint8Array()]); + jest.spyOn(coreDatabase, 'put').mockRejectedValueOnce(new Error('DB write failed')); + jest.spyOn(apiClient.api.client, 'uploadMLSKeyPackages'); + const emitSpy = jest.spyOn(mlsService, 'emit'); + + await expect(mlsService['verifyRemoteMLSKeyPackagesAmount']('client-1')).resolves.toBeUndefined(); + + expect(transactionContext.clientKeypackages).toHaveBeenCalled(); + expect(apiClient.api.client.uploadMLSKeyPackages).toHaveBeenCalled(); + expect(await mlsService.isMLSConversationRecoveryRequired()).toBe(false); + expect(emitSpy).not.toHaveBeenCalledWith(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + }); + + it('resumes persisted recovery after restart when packages are already available', async () => { + const [mlsService, {apiClient, coreDatabase}] = await createMLSService(); + await coreDatabase.put('mlsConversationRecovery', {required: true}, 'required'); + jest.spyOn(apiClient.api.client, 'getMLSKeyPackageCount').mockResolvedValueOnce(mlsService.config.nbKeyPackages); + jest.spyOn(apiClient.api.client, 'uploadMLSKeyPackages'); + const emitSpy = jest.spyOn(mlsService, 'emit'); + + expect(await mlsService.prepareMLSConversationRecovery('client-1')).toBe(true); + + expect(apiClient.api.client.uploadMLSKeyPackages).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + }); + + it('clears recovery only when explicitly completed', async () => { + const [mlsService, {coreDatabase}] = await createMLSService(); + await coreDatabase.put('mlsConversationRecovery', {required: true}, 'required'); + + await mlsService.completeMLSConversationRecovery(); + + expect(await mlsService.isMLSConversationRecoveryRequired()).toBe(false); + }); + + it('tracks pending conversation IDs during recovery', async () => { + const [mlsService, {coreDatabase}] = await createMLSService(); + await coreDatabase.put('mlsConversationRecovery', {required: true}, 'required'); + + const pendingIds = [ + {id: 'conv1', domain: 'domain.com'}, + {id: 'conv2', domain: 'domain.com'}, + {id: 'conv3', domain: 'domain.com'}, + ]; + await mlsService.updatePendingRecoveryConversationIds(pendingIds); + + expect(await mlsService.getPendingRecoveryConversationIds()).toEqual(pendingIds); + }); + + it('removes conversations from pending list as they are recovered', async () => { + const [mlsService, {coreDatabase}] = await createMLSService(); + await coreDatabase.put('mlsConversationRecovery', {required: true}, 'required'); + const initialPendingIds = [ + {id: 'conv1', domain: 'domain.com'}, + {id: 'conv2', domain: 'domain.com'}, + {id: 'conv3', domain: 'domain.com'}, + ]; + await mlsService.updatePendingRecoveryConversationIds(initialPendingIds); + + const updatedPendingIds = initialPendingIds.filter(({id}) => id !== 'conv2'); + await mlsService.updatePendingRecoveryConversationIds(updatedPendingIds); + + expect(await mlsService.getPendingRecoveryConversationIds()).toEqual([ + {id: 'conv1', domain: 'domain.com'}, + {id: 'conv3', domain: 'domain.com'}, + ]); + }); + }); + describe('wipeConversation', () => { it('wipes a group and cancels its timers', async () => { const [mlsService, {recurringTaskScheduler, coreCrypto, transactionContext}] = await createMLSService(); diff --git a/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.ts b/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.ts index 944b6208ebd..95bafb860a9 100644 --- a/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.ts +++ b/libraries/core/src/messagingProtocols/mls/mlsService/mlsService.ts @@ -119,6 +119,7 @@ export enum MLSServiceEvents { NEW_CRL_DISTRIBUTION_POINTS = 'newCrlDistributionPoints', MLS_EVENT_DISTRIBUTED = 'mlsEventDistributed', KEY_MATERIAL_UPDATE_FAILURE = 'keyMaterialUpdateFailure', + MLS_CONVERSATION_RECOVERY_REQUIRED = 'mlsConversationRecoveryRequired', } type Events = { @@ -130,7 +131,10 @@ type Events = { events: any; time: string; }; + [MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED]: void; }; + +const MLS_CONVERSATION_RECOVERY_KEY = 'required'; export class MLSService extends TypedEventEmitter { logger = LogFactory.getLogger('@wireapp/core/MLSService'); private _config?: MLSConfig; @@ -952,14 +956,66 @@ export class MLSService extends TypedEventEmitter { private async verifyRemoteMLSKeyPackagesAmount(clientId: string) { const backendKeyPackagesCount = await this.getRemoteMLSKeyPackageCount(clientId); + let isConversationRecoveryRequired = await this.isMLSConversationRecoveryRequired(); // If we have enough keys uploaded on backend, there's no need to upload more. if (backendKeyPackagesCount > this.minRequiredKeyPackages) { + if (isConversationRecoveryRequired) { + this.emit(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + } return; } const keyPackages = await this.clientKeypackages(this.config.nbKeyPackages); - return this.uploadMLSKeyPackages(clientId, keyPackages); + await this.uploadMLSKeyPackages(clientId, keyPackages); + + // Mark recovery only after a successful upload, so the marker never outlives a failed refill attempt. + if (backendKeyPackagesCount === 0 && !isConversationRecoveryRequired) { + try { + await this.coreDatabase.put('mlsConversationRecovery', {required: true}, MLS_CONVERSATION_RECOVERY_KEY); + isConversationRecoveryRequired = true; + } catch (error: unknown) { + this.logger.error('Failed to persist MLS conversation recovery marker', error); + } + } + + if (isConversationRecoveryRequired) { + this.emit(MLSServiceEvents.MLS_CONVERSATION_RECOVERY_REQUIRED); + } + } + + public async isMLSConversationRecoveryRequired(): Promise { + const recoveryState = await this.coreDatabase.get('mlsConversationRecovery', MLS_CONVERSATION_RECOVERY_KEY); + return recoveryState?.required === true; + } + + public async prepareMLSConversationRecovery(clientId: string): Promise { + if (!(await this.isMLSConversationRecoveryRequired())) { + return false; + } + + await this.verifyRemoteMLSKeyPackagesAmount(clientId); + return true; + } + + public async completeMLSConversationRecovery(): Promise { + await this.coreDatabase.delete('mlsConversationRecovery', MLS_CONVERSATION_RECOVERY_KEY); + } + + public async getPendingRecoveryConversationIds(): Promise { + const recoveryState = await this.coreDatabase.get('mlsConversationRecovery', MLS_CONVERSATION_RECOVERY_KEY); + return recoveryState?.pendingConversationIds; + } + + public async updatePendingRecoveryConversationIds(conversationIds: QualifiedId[]): Promise { + const recoveryState = await this.coreDatabase.get('mlsConversationRecovery', MLS_CONVERSATION_RECOVERY_KEY); + if (recoveryState !== undefined) { + await this.coreDatabase.put( + 'mlsConversationRecovery', + {...recoveryState, pendingConversationIds: conversationIds}, + MLS_CONVERSATION_RECOVERY_KEY, + ); + } } private async getRemoteMLSKeyPackageCount(clientId: string) { diff --git a/libraries/core/src/storage/coreDb.ts b/libraries/core/src/storage/coreDb.ts index 9b934ee785c..276d166989c 100644 --- a/libraries/core/src/storage/coreDb.ts +++ b/libraries/core/src/storage/coreDb.ts @@ -22,7 +22,7 @@ import {QualifiedId} from '@wireapp/api-client/lib/user'; import {DBSchema, deleteDB as idbDeleteDB, IDBPDatabase, openDB as idbOpenDb} from 'idb'; import {EnrollmentFlowData} from '../messagingProtocols/mls/e2eIdentityService/storage/e2eiStorage.schema'; -const VERSION = 6; +const VERSION = 7; interface CoreDBSchema extends DBSchema { prekeys: { @@ -53,6 +53,10 @@ interface CoreDBSchema extends DBSchema { key: string; value: EnrollmentFlowData; }; + mlsConversationRecovery: { + key: string; + value: {required: true; pendingConversationIds?: QualifiedId[]}; + }; } export type CoreDatabase = IDBPDatabase; @@ -74,8 +78,9 @@ export async function openDB(dbName: string): Promise { db.createObjectStore('subconversations'); case 5: db.createObjectStore('crls'); - case 6: db.createObjectStore('pendingEnrollmentData'); + case 6: + db.createObjectStore('mlsConversationRecovery'); } }, }); From dc2ab8133da4a2f4703d6126de05d86fe124307b Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 3 Sep 2026 10:11:50 +0200 Subject: [PATCH 2/2] fix: lint --- apps/webapp/src/script/mls/MLSConversations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/src/script/mls/MLSConversations.ts b/apps/webapp/src/script/mls/MLSConversations.ts index 7169cd1fa8d..9e2cf66ec8f 100644 --- a/apps/webapp/src/script/mls/MLSConversations.ts +++ b/apps/webapp/src/script/mls/MLSConversations.ts @@ -17,6 +17,7 @@ * */ +import {isNonEmptyArray} from '@sindresorhus/is'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import {Maybe, task} from 'true-myth'; import {match, P} from 'ts-pattern'; @@ -39,7 +40,6 @@ import {User} from 'Repositories/entity/User'; import {UserState} from 'Repositories/user/userState'; import {getLogger} from 'Util/logger'; import {matchQualifiedIds} from 'Util/qualifiedId'; -import {isNonEmptyArray} from '@sindresorhus/is'; const logger = getLogger('Webapp/MLSConversations');