From 589d3676e2717800d4163e73b2a3e9d90eb90553 Mon Sep 17 00:00:00 2001 From: John Nguyen <28632506+johnxnguyen@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:52:22 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20repair=20faulty=20removal=20keys=20-=20W?= =?UTF-8?q?PB-22447=20=F0=9F=8D=92=20(#4042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: François Benaiteau --- .../Components/ClientSessionComponent.swift | 15 +- .../Components/UserSessionComponent.swift | 9 +- .../InitiateResetMLSConversationUseCase.swift | 1 + .../LocalStore/ConversationLocalStore.swift | 10 + .../ConversationLocalStoreProtocol.swift | 15 + .../UseCases/RepairRemovalKeysUseCase.swift | 199 +++++++++++++ .../generated/AutoMockable.generated.swift | 76 +++++ ...iateResetMLSConversationUseCaseTests.swift | 0 .../RepairRemovalKeysUseCaseTests.swift | 263 ++++++++++++++++++ .../Conversation/ConversationList.swift | 10 + wire-ios-build-assets | 2 +- .../Source/MLS/MLSService.swift | 6 + .../Source/MLS/MLSServiceInterface.swift | 2 + .../ZMConversation+Messaging.swift | 18 +- .../generated/AutoMockable.generated.swift | 23 ++ .../Sources/SharingSession.swift | 4 +- .../Sources/SharingSessionLoader.swift | 3 +- .../SessionManager/SessionFactories.swift | 6 +- .../SessionManager/SessionManager.swift | 6 +- .../SessionManagerConfiguration.swift | 19 +- .../SessionManager/UserSessionLoader.swift | 8 +- .../ZMUserSession/ZMUserSession.swift | 6 +- .../ZMUserSession/ZMUserSessionBuilder.swift | 8 +- .../APIMigrationManagerTests.swift | 3 +- .../UserSession/ZMUserSessionTestsBase.swift | 3 +- .../ZMUserSessionTests_NetworkState.swift | 3 +- .../DeveloperDebugActionsViewModelTests.swift | 2 +- .../DeveloperDebugActionsViewModel.swift | 26 ++ 28 files changed, 721 insertions(+), 25 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/UseCases/RepairRemovalKeysUseCase.swift rename WireDomain/Tests/WireDomainTests/{Helpers => UseCases}/InitiateResetMLSConversationUseCaseTests.swift (100%) create mode 100644 WireDomain/Tests/WireDomainTests/UseCases/RepairRemovalKeysUseCaseTests.swift diff --git a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift index e3d39688c04..82aa64a759c 100644 --- a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift +++ b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift @@ -65,6 +65,8 @@ public final class ClientSessionComponent { private let coreCryptoProvider: any CoreCryptoProviderProtocol private let completionHandlers: CompletionHandlers + private let faultyMLSRemovalKeysByDomain: [String: [String]] + public init( selfUserID: UUID, selfClientID: String, @@ -81,7 +83,8 @@ public final class ClientSessionComponent { mlsDecryptionService: any MLSDecryptionServiceInterface, proteusService: any ProteusServiceInterface, coreCryptoProvider: any CoreCryptoProviderProtocol, - completionHandlers: CompletionHandlers + completionHandlers: CompletionHandlers, + faultyMLSRemovalKeysByDomain: [String: [String]] ) { self.selfUserID = selfUserID self.selfClientID = selfClientID @@ -99,6 +102,7 @@ public final class ClientSessionComponent { self.isMLSEnabled = isMLSEnabled self.coreCryptoProvider = coreCryptoProvider self.completionHandlers = completionHandlers + self.faultyMLSRemovalKeysByDomain = faultyMLSRemovalKeysByDomain } public private(set) lazy var authenticationManager = AuthenticationManager( @@ -793,6 +797,15 @@ public final class ClientSessionComponent { userID: selfUserID ) + public lazy var repairFaultyRemovalKeysUsecase = RepairRemovalKeysUseCase( + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain, + context: syncContext, + mlsService: mlsService, + conversationsAPI: conversationsAPI, + conversationLocalStore: conversationLocalStore, + initiateResetUseCase: initiateResetMLSConversationUseCase + ) + public lazy var initiateResetMLSConversationUseCase = InitiateResetMLSConversationUseCase( api: mlsAPI, mlsService: mlsService, diff --git a/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift b/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift index 8ba2d07f919..ccdc6f77a01 100644 --- a/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift +++ b/WireDomain/Sources/WireDomain/Components/UserSessionComponent.swift @@ -43,6 +43,8 @@ public final class UserSessionComponent { private let proteusService: any ProteusServiceInterface private let coreCryptoProvider: any CoreCryptoProviderProtocol + private let faultyMLSRemovalKeysByDomain: [String: [String]] + public init( currentBuildNumber: String, selfUserID: UUID, @@ -59,7 +61,8 @@ public final class UserSessionComponent { mlsService: any MLSServiceInterface, mlsDecryptionService: any MLSDecryptionServiceInterface, proteusService: any ProteusServiceInterface, - coreCryptoProvider: any CoreCryptoProviderProtocol + coreCryptoProvider: any CoreCryptoProviderProtocol, + faultyMLSRemovalKeysByDomain: [String: [String]] ) { self.currentBuildNumber = currentBuildNumber self.selfUserID = selfUserID @@ -77,6 +80,7 @@ public final class UserSessionComponent { self.proteusService = proteusService self.coreCryptoProvider = coreCryptoProvider self.sharedContainerURL = sharedContainerURL + self.faultyMLSRemovalKeysByDomain = faultyMLSRemovalKeysByDomain } private let cookieStorage: any CookieStorageProtocol @@ -103,7 +107,8 @@ public final class UserSessionComponent { mlsDecryptionService: mlsDecryptionService, proteusService: proteusService, coreCryptoProvider: coreCryptoProvider, - completionHandlers: completionHandlers + completionHandlers: completionHandlers, + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain ) } diff --git a/WireDomain/Sources/WireDomain/Helpers/InitiateResetMLSConversationUseCase.swift b/WireDomain/Sources/WireDomain/Helpers/InitiateResetMLSConversationUseCase.swift index 5792edce9a5..fbe003d9460 100644 --- a/WireDomain/Sources/WireDomain/Helpers/InitiateResetMLSConversationUseCase.swift +++ b/WireDomain/Sources/WireDomain/Helpers/InitiateResetMLSConversationUseCase.swift @@ -21,6 +21,7 @@ import WireDataModel import WireLogging import WireNetwork +// sourcery: AutoMockable public protocol InitiateResetMLSConversationUseCaseProtocol { func invoke(groupID: WireDataModel.MLSGroupID, epoch: UInt64) async } diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift index 9a291c2912d..6eb790813f2 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift @@ -201,6 +201,16 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { } } + public func fetchAllMLSConversations(domain: String?) async throws -> [ZMConversation] { + try await context.perform { [context] in + try ZMConversation.fetchConversationsWithMLSGroupStatus( + mlsGroupStatus: .ready, + domain: domain, + in: context + ) + } + } + public func fetchConversation( id: UUID, domain: String? diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift index 3abd1de01ae..88788f034fb 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift @@ -84,6 +84,21 @@ public protocol ConversationLocalStoreProtocol { mlsGroupID: MLSGroupID ) async + /// Fetches all MLS conversations that are ready. + /// + /// This method retrieves all conversations that have MLS group IDs and are in a ready state, + /// optionally filtered by domain. + /// + /// - Parameter domain: The domain to filter conversations by. If `nil`, fetches conversations + /// from all domains. + /// + /// - Returns: An array of `ZMConversation` objects that are MLS-ready. Returns an empty array + /// if no conversations match the criteria. + /// + /// - Throws: An error if the fetch operation fails. + + func fetchAllMLSConversations(domain: String?) async throws -> [ZMConversation] + /// Fetches a MLS conversation locally. /// /// - parameters: diff --git a/WireDomain/Sources/WireDomain/UseCases/RepairRemovalKeysUseCase.swift b/WireDomain/Sources/WireDomain/UseCases/RepairRemovalKeysUseCase.swift new file mode 100644 index 00000000000..512e073c3d9 --- /dev/null +++ b/WireDomain/Sources/WireDomain/UseCases/RepairRemovalKeysUseCase.swift @@ -0,0 +1,199 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel +import WireLogging +import WireNetwork + +// sourcery: AutoMockable +/// Repairs conversations with faulty removal keys +public protocol RepairRemovalKeysUseCaseProtocol { + func invoke() async throws +} + +public struct RepairRemovalKeysUseCase: RepairRemovalKeysUseCaseProtocol { + + let faultyMLSRemovalKeysByDomain: [String: [String]] + + private let context: NSManagedObjectContext + private let mlsService: MLSServiceInterface + private let conversationsAPI: ConversationsAPI + private let conversationLocalStore: ConversationLocalStoreProtocol + private let initiateResetUseCase: InitiateResetMLSConversationUseCaseProtocol + + init( + faultyMLSRemovalKeysByDomain: [String: [String]], + context: NSManagedObjectContext, + mlsService: MLSServiceInterface, + conversationsAPI: ConversationsAPI, + conversationLocalStore: ConversationLocalStoreProtocol, + initiateResetUseCase: InitiateResetMLSConversationUseCaseProtocol + ) { + self.faultyMLSRemovalKeysByDomain = faultyMLSRemovalKeysByDomain + self.context = context + self.mlsService = mlsService + self.conversationsAPI = conversationsAPI + self.conversationLocalStore = conversationLocalStore + self.initiateResetUseCase = initiateResetUseCase + } + + public func invoke() async throws { + WireLogger.mls.info( + "initiating repair of faulty removal keys", + attributes: .safePublic + ) + + guard !faultyMLSRemovalKeysByDomain.isEmpty else { + WireLogger.mls.info( + "no faulty removal keys to repair, aborting", + attributes: .safePublic + ) + return + } + + // Process each domain + for (domain, faultyKeyHexStrings) in faultyMLSRemovalKeysByDomain { + try await processDomain( + domain: domain, + faultyKeyHexStrings: faultyKeyHexStrings + ) + } + } + + // MARK: - Private + + private func processDomain( + domain: String, + faultyKeyHexStrings: [String] + ) async throws { + WireLogger.mls.info( + "checking domain for \(faultyKeyHexStrings.count) faulty key(s)", + attributes: .safePublic + ) + + // Convert hex strings to Data + let faultyKeyDataList = faultyKeyHexStrings.compactMap(Data.init(hexString:)) + guard faultyKeyDataList.count == faultyKeyHexStrings.count else { + WireLogger.mls.error( + "failed to decode some faulty removal key hex strings", + attributes: .safePublic + ) + return + } + + let allMLSConversations = try await conversationLocalStore.fetchAllMLSConversations( + domain: domain + ) + + // Find faulty conversations for this domain + let faultyConversations = await findFaultyConversations( + in: allMLSConversations, + faultyKeys: faultyKeyDataList + ) + + WireLogger.mls.info( + "detected \(faultyConversations.count)/\(allMLSConversations.count) affected conversations", + attributes: .safePublic + ) + + // Repair each faulty conversation in parallel + await withTaskGroup(of: Void.self) { group in + for (groupID, qualifiedID) in faultyConversations { + group.addTask { + await repairConversation( + groupID: groupID, + qualifiedID: qualifiedID + ) + } + } + } + } + + private func findFaultyConversations( + in conversations: [ZMConversation], + faultyKeys: [Data] + ) async -> [(MLSGroupID, WireDataModel.QualifiedID)] { + var faultyConversations: [(MLSGroupID, WireDataModel.QualifiedID)] = [] + + for conversation in conversations { + let (groupID, qualifiedID) = await context.perform { + (conversation.mlsGroupID, conversation.qualifiedID) + } + + guard let groupID, let qualifiedID else { + continue + } + + let currentRemovalKey: Data + do { + currentRemovalKey = try await mlsService.externalSenderKey(groupID: groupID) + } catch { + WireLogger.mls.error( + "failed to get current removal key for a group, skipping: \(String(describing: error))", + attributes: .safePublic + ) + continue + } + + // Check if the current removal key matches any of the faulty keys + if faultyKeys.contains(currentRemovalKey) { + faultyConversations.append(( + groupID, + qualifiedID + )) + } + } + + return faultyConversations + } + + private func repairConversation( + groupID: MLSGroupID, + qualifiedID: WireDataModel.QualifiedID + ) async { + let remoteConversation: WireNetwork.Conversation? + do { + remoteConversation = try await conversationsAPI.getConversations( + for: [qualifiedID.toAPIModel()] + ).found.first + } catch { + WireLogger.mls.error( + "failed to get epoch for a group, skipping: \(String(describing: error))", + attributes: .safePublic, [.conversationId: qualifiedID.safeForLoggingDescription] + ) + return + } + + guard let remoteConversation else { + WireLogger.mls.error( + "remote conversation for a group not found, skipping", + attributes: .safePublic, [.conversationId: qualifiedID.safeForLoggingDescription] + ) + return + } + + WireLogger.mls.info( + "initiating reset for faulty conversation: \(qualifiedID)", + attributes: .safePublic, [.conversationId: qualifiedID.safeForLoggingDescription] + ) + + let epoch = UInt64(remoteConversation.epoch ?? 0) + await initiateResetUseCase.invoke(groupID: groupID, epoch: epoch) + } + +} diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index a47c75145d7..fe897e84a07 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -755,6 +755,29 @@ public class MockConversationLocalStoreProtocol: ConversationLocalStoreProtocol await mock(conversationID, conversationDomain, mlsGroupID) } + // MARK: - fetchAllMLSConversations + + public var fetchAllMLSConversationsDomain_Invocations: [String?] = [] + public var fetchAllMLSConversationsDomain_MockError: Error? + public var fetchAllMLSConversationsDomain_MockMethod: ((String?) async throws -> [ZMConversation])? + public var fetchAllMLSConversationsDomain_MockValue: [ZMConversation]? + + public func fetchAllMLSConversations(domain: String?) async throws -> [ZMConversation] { + fetchAllMLSConversationsDomain_Invocations.append(domain) + + if let error = fetchAllMLSConversationsDomain_MockError { + throw error + } + + if let mock = fetchAllMLSConversationsDomain_MockMethod { + return try await mock(domain) + } else if let mock = fetchAllMLSConversationsDomain_MockValue { + return mock + } else { + fatalError("no mock for `fetchAllMLSConversationsDomain`") + } + } + // MARK: - fetchMLSConversation public var fetchMLSConversationGroupID_Invocations: [WireDataModel.MLSGroupID] = [] @@ -2475,6 +2498,30 @@ public class MockInitialSyncProtocol: InitialSyncProtocol { } +public class MockInitiateResetMLSConversationUseCaseProtocol: InitiateResetMLSConversationUseCaseProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - invoke + + public var invokeGroupIDEpoch_Invocations: [(groupID: WireDataModel.MLSGroupID, epoch: UInt64)] = [] + public var invokeGroupIDEpoch_MockMethod: ((WireDataModel.MLSGroupID, UInt64) async -> Void)? + + public func invoke(groupID: WireDataModel.MLSGroupID, epoch: UInt64) async { + invokeGroupIDEpoch_Invocations.append((groupID: groupID, epoch: epoch)) + + guard let mock = invokeGroupIDEpoch_MockMethod else { + fatalError("no mock for `invokeGroupIDEpoch`") + } + + await mock(groupID, epoch) + } + +} + public class MockLiveGeneratorProtocol: LiveGeneratorProtocol { // MARK: - Life cycle @@ -3835,6 +3882,35 @@ public class MockPushSupportedProtocolsUseCaseProtocol: PushSupportedProtocolsUs } +public class MockRepairRemovalKeysUseCaseProtocol: RepairRemovalKeysUseCaseProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - invoke + + public var invoke_Invocations: [Void] = [] + public var invoke_MockError: Error? + public var invoke_MockMethod: (() async throws -> Void)? + + public func invoke() async throws { + invoke_Invocations.append(()) + + if let error = invoke_MockError { + throw error + } + + guard let mock = invoke_MockMethod else { + fatalError("no mock for `invoke`") + } + + try await mock() + } + +} + public class MockResetMLSConversationLockRepositoryProtocol: ResetMLSConversationLockRepositoryProtocol { // MARK: - Life cycle diff --git a/WireDomain/Tests/WireDomainTests/Helpers/InitiateResetMLSConversationUseCaseTests.swift b/WireDomain/Tests/WireDomainTests/UseCases/InitiateResetMLSConversationUseCaseTests.swift similarity index 100% rename from WireDomain/Tests/WireDomainTests/Helpers/InitiateResetMLSConversationUseCaseTests.swift rename to WireDomain/Tests/WireDomainTests/UseCases/InitiateResetMLSConversationUseCaseTests.swift diff --git a/WireDomain/Tests/WireDomainTests/UseCases/RepairRemovalKeysUseCaseTests.swift b/WireDomain/Tests/WireDomainTests/UseCases/RepairRemovalKeysUseCaseTests.swift new file mode 100644 index 00000000000..1684e942b55 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/UseCases/RepairRemovalKeysUseCaseTests.swift @@ -0,0 +1,263 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation +import Testing +import WireDataModel +import WireDataModelSupport +import WireNetwork +@testable import WireDomain +@testable import WireDomainSupport +@testable import WireNetworkSupport + +struct RepairRemovalKeysUseCaseTests { + + let sut: RepairRemovalKeysUseCase + let coreDataStack: CoreDataStack + let mlsService = MockMLSServiceInterface() + let conversationsAPI = MockConversationsAPIProtocol() + let conversationLocalStore: ConversationLocalStore + let initiateResetUseCase = MockInitiateResetMLSConversationUseCaseProtocol() + + let validKey = Data([4, 5, 6]) + let faultyKey = Data([1, 2, 3]) + let affectedDomain = "apple.com" + + let affectedGroupMLSGroupID = MLSGroupID.random() + let affectedOneOnOneMLSGroupID = MLSGroupID.random() + let nonAffectedGroupMLSGroupID = MLSGroupID.random() + let otherDomainMLSGroupID = MLSGroupID.random() + + init() async throws { + let coreDataStackHelper = CoreDataStackHelper(localDomain: affectedDomain) + self.coreDataStack = try await coreDataStackHelper.createStack() + let context = coreDataStack.syncContext + let messageLocalStore = MessageLocalStore(context: context) + self.conversationLocalStore = ConversationLocalStore( + context: context, + mlsService: mlsService, + messageLocalStore: messageLocalStore, + localDomain: affectedDomain, + isFederationEnabled: true + ) + self.sut = RepairRemovalKeysUseCase( + faultyMLSRemovalKeysByDomain: [affectedDomain: [Data([1, 2, 3]).zmHexEncodedString()]], + context: context, + mlsService: mlsService, + conversationsAPI: conversationsAPI, + conversationLocalStore: conversationLocalStore, + initiateResetUseCase: initiateResetUseCase + ) + + await context.perform { [self] in + let modelHelper = ModelHelper() + let selfUser = modelHelper.createSelfUser( + id: UUID(), + domain: affectedDomain, + in: context + ) + let otherUser = modelHelper.createUser( + id: UUID(), + domain: affectedDomain, + supportedProtocols: [.mls], + in: context + ) + modelHelper.createMLSConversation( + id: UUID(), + domain: affectedDomain, + mlsGroupID: affectedGroupMLSGroupID, + mlsStatus: .ready, + conversationType: .group, + epoch: 0, + with: [selfUser, otherUser], + in: context + ) + modelHelper.createMLSConversation( + id: UUID(), + domain: "banana.com", + mlsGroupID: otherDomainMLSGroupID, + mlsStatus: .ready, + conversationType: .group, + epoch: 0, + with: [selfUser, otherUser], + in: context + ) + modelHelper.createMLSConversation( + id: UUID(), + domain: affectedDomain, + mlsGroupID: affectedOneOnOneMLSGroupID, + mlsStatus: .ready, + conversationType: .oneOnOne, + epoch: 0, + with: [selfUser, otherUser], + in: context + ) + modelHelper.createMLSConversation( + id: UUID(), + domain: affectedDomain, + mlsGroupID: nonAffectedGroupMLSGroupID, + mlsStatus: .pendingJoin, + conversationType: .group, + epoch: 0, + with: [selfUser, otherUser], + in: context + ) + } + + initiateResetUseCase.invokeGroupIDEpoch_MockMethod = { _, _ in } + } + + // MARK: - Tests + + @Test("It resets groups with faulty keys") + func itResetsGroupsWithFaultyKeys() async throws { + // Given + // MLS group and 1-1 have faulty keys + mlsService.externalSenderKeyGroupID_MockMethod = { groupID in + switch groupID { + case affectedGroupMLSGroupID, affectedOneOnOneMLSGroupID: + faultyKey + default: + validKey + } + } + + // When + try await sut.invoke() + + // Then + // The reset is initated for those conversations. + let invocations = initiateResetUseCase.invokeGroupIDEpoch_Invocations + #expect(Set(invocations.map(\.groupID)) == [affectedGroupMLSGroupID, affectedOneOnOneMLSGroupID]) + #expect(Set(invocations.map(\.epoch)) == [5, 5]) + } + + @Test("It does not reset groups with valid keys") + func itDoesNotResetGroupsWithValidKeys() async throws { + // Given + // All groups have valid keys + mlsService.externalSenderKeyGroupID_MockMethod = { _ in + validKey + } + + // When + try await sut.invoke() + + // Then + // No group reset has been initiated + let invocations = initiateResetUseCase.invokeGroupIDEpoch_Invocations + #expect(invocations.isEmpty) + } + + @Test("It does not reset groups that are not MLS ready") + func itDoesNotResetGroupsThatAreNotMLSReady() async throws { + // Given + // MLS group that is not ready + mlsService.externalSenderKeyGroupID_MockMethod = { groupID in + switch groupID { + case nonAffectedGroupMLSGroupID: + faultyKey + default: + validKey + } + } + + // When + try await sut.invoke() + + // Then + // No group reset has been initiated + let invocations = initiateResetUseCase.invokeGroupIDEpoch_Invocations + #expect(invocations.isEmpty) + } + + @Test("It does not reset groups from other domains") + func itDoesNotResetGroupsFromOtherDomains() async throws { + // Given + // MLS group from another domain + mlsService.externalSenderKeyGroupID_MockMethod = { groupID in + switch groupID { + case otherDomainMLSGroupID: + faultyKey + default: + validKey + } + } + + // When + try await sut.invoke() + + // Then + // No group reset has been initiated + let invocations = initiateResetUseCase.invokeGroupIDEpoch_Invocations + #expect(invocations.isEmpty) + } + +} + +// MARK: - Mock ConversationsAPI + +// TODO: [WPB-22478] Remove this mock when we generate it in WireNetwork +final class MockConversationsAPIProtocol: ConversationsAPI { + + func getLegacyConversationIdentifiers() async throws -> WireNetwork.PayloadPager<[UUID]> { + fatalError("not implemented") + } + + func getConversationIdentifiers() async throws -> WireNetwork.PayloadPager<[WireNetwork.QualifiedID]> { + fatalError("not implemented") + } + + func getConversations( + for identifiers: [WireNetwork.QualifiedID] + ) async throws -> WireNetwork.ConversationList { + let conversation = WireNetwork.Conversation(epoch: 5) + return .init(found: [conversation], notFound: [], failed: []) + } + + func getMLSOneToOneConversation( + userID: String, + in domain: String + ) async throws -> ( + WireNetwork.Conversation, + WireNetwork.MLSPublicKeys? + ) { + fatalError("not implemented") + } + + func getConversationGuestLink( + conversationID: String + ) async throws -> String? { + fatalError("not implemented") + } + + func createGroupConversation( + parameters: WireNetwork.CreateGroupConversationParameters + ) async throws -> WireNetwork.Conversation { + fatalError("not implemented") + } + + func addChannelPermission( + conversationID: String, + conversationDomain: String, + permission: WireNetwork.ChannelPermission + ) async throws -> WireNetwork.ChannelPermission { + fatalError("not implemented") + } + +} diff --git a/WireNetwork/Sources/WireNetwork/Models/Conversation/ConversationList.swift b/WireNetwork/Sources/WireNetwork/Models/Conversation/ConversationList.swift index 35234698575..dfca012c707 100644 --- a/WireNetwork/Sources/WireNetwork/Models/Conversation/ConversationList.swift +++ b/WireNetwork/Sources/WireNetwork/Models/Conversation/ConversationList.swift @@ -27,4 +27,14 @@ public struct ConversationList: Sendable { /// Identifies conversations that failed to resolve and thus miss the representing objects. public let failed: [QualifiedID] + + public init( + found: [Conversation], + notFound: [QualifiedID], + failed: [QualifiedID] + ) { + self.found = found + self.notFound = notFound + self.failed = failed + } } diff --git a/wire-ios-build-assets b/wire-ios-build-assets index 5ef7d25db0f..010f2d0a46f 160000 --- a/wire-ios-build-assets +++ b/wire-ios-build-assets @@ -1 +1 @@ -Subproject commit 5ef7d25db0fb45c03ebc31203da2c96bcf425917 +Subproject commit 010f2d0a46f777b9c04955d5d44e5734afb5f108 diff --git a/wire-ios-data-model/Source/MLS/MLSService.swift b/wire-ios-data-model/Source/MLS/MLSService.swift index 7b8183ca0d3..d61ae7dfe2c 100644 --- a/wire-ios-data-model/Source/MLS/MLSService.swift +++ b/wire-ios-data-model/Source/MLS/MLSService.swift @@ -752,6 +752,12 @@ public final class MLSService: MLSServiceInterface { } + public func externalSenderKey(groupID: MLSGroupID) async throws -> Data { + try await coreCrypto.perform { coreCrypto in + try await coreCrypto.getExternalSender(conversationId: groupID.conversationId) + }.copyBytes() + } + public func conversationExists(groupID: MLSGroupID) async throws -> Bool { logger.info("checking if group (\(groupID)) exists...") diff --git a/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift b/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift index 4acc2e77745..bad944aaf09 100644 --- a/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift +++ b/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift @@ -160,6 +160,8 @@ public protocol MLSServiceInterface: MLSEncryptionServiceInterface, MLSDecryptio func wipeGroup(_ groupID: MLSGroupID) async throws + func externalSenderKey(groupID: MLSGroupID) async throws -> Data + /// Checks if the group exists in core crypto's local storage /// /// - Parameter groupID: The ID of the group to check diff --git a/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Messaging.swift b/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Messaging.swift index 6536a165c18..07c96e68bfa 100644 --- a/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Messaging.swift +++ b/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Messaging.swift @@ -210,18 +210,30 @@ public extension ZMConversation { static func fetchConversationsWithMLSGroupStatus( mlsGroupStatus: MLSGroupStatus, + domain: String? = nil, in context: NSManagedObjectContext ) throws -> [ZMConversation] { let request = NSFetchRequest(entityName: ZMConversation.entityName()) + let matchingGroupStatus = NSPredicate( format: "%K == \(mlsGroupStatus.rawValue)", argumentArray: [Self.mlsStatusKey] ) - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - matchingGroupStatus, .isMLSConversation - ]) + var matchingDomain: NSPredicate? + if let domain { + matchingDomain = NSPredicate( + format: "%K == %@", + argumentArray: [Self.domainKey(), domain] + ) + } + + request.predicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [ + matchingGroupStatus, .isMLSConversation, matchingDomain + ].compactMap(\.self) + ) return try context.fetch(request) } diff --git a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift index ba1457b4173..31c90d2f49c 100644 --- a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift +++ b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift @@ -4378,6 +4378,29 @@ public class MockMLSServiceInterface: MLSServiceInterface { try await mock(groupID) } + // MARK: - externalSenderKey + + public var externalSenderKeyGroupID_Invocations: [MLSGroupID] = [] + public var externalSenderKeyGroupID_MockError: Error? + public var externalSenderKeyGroupID_MockMethod: ((MLSGroupID) async throws -> Data)? + public var externalSenderKeyGroupID_MockValue: Data? + + public func externalSenderKey(groupID: MLSGroupID) async throws -> Data { + externalSenderKeyGroupID_Invocations.append(groupID) + + if let error = externalSenderKeyGroupID_MockError { + throw error + } + + if let mock = externalSenderKeyGroupID_MockMethod { + return try await mock(groupID) + } else if let mock = externalSenderKeyGroupID_MockValue { + return mock + } else { + fatalError("no mock for `externalSenderKeyGroupID`") + } + } + // MARK: - conversationExists public var conversationExistsGroupID_Invocations: [MLSGroupID] = [] diff --git a/wire-ios-share-engine/Sources/SharingSession.swift b/wire-ios-share-engine/Sources/SharingSession.swift index 427f0039d11..6dace382cc4 100644 --- a/wire-ios-share-engine/Sources/SharingSession.swift +++ b/wire-ios-share-engine/Sources/SharingSession.swift @@ -459,7 +459,9 @@ public final class SharingSession { mlsService: mlsService, mlsDecryptionService: mlsService, proteusService: proteusService, - coreCryptoProvider: coreCryptoProvider + coreCryptoProvider: coreCryptoProvider, + faultyMLSRemovalKeysByDomain: [:] // not relevant + ) let completionHandlers = ClientSessionComponent.CompletionHandlers( diff --git a/wire-ios-share-engine/Sources/SharingSessionLoader.swift b/wire-ios-share-engine/Sources/SharingSessionLoader.swift index 24a75fc232d..d2ca0e8aeeb 100644 --- a/wire-ios-share-engine/Sources/SharingSessionLoader.swift +++ b/wire-ios-share-engine/Sources/SharingSessionLoader.swift @@ -384,7 +384,8 @@ public struct SharingSessionLoader { mlsService: mlsService, mlsDecryptionService: mlsService, proteusService: proteusService, - coreCryptoProvider: coreCryptoProvider + coreCryptoProvider: coreCryptoProvider, + faultyMLSRemovalKeysByDomain: [:] // not relevant ) let completionHandlers = ClientSessionComponent.CompletionHandlers( onProcessedCallEvent: { _ in }, diff --git a/wire-ios-sync-engine/Source/SessionManager/SessionFactories.swift b/wire-ios-sync-engine/Source/SessionManager/SessionFactories.swift index 1e21c081f33..f51da3f9234 100644 --- a/wire-ios-sync-engine/Source/SessionManager/SessionFactories.swift +++ b/wire-ios-sync-engine/Source/SessionManager/SessionFactories.swift @@ -66,7 +66,8 @@ open class AuthenticatedSessionFactory { sharedUserDefaults: UserDefaults, isDeveloperModeEnabled: Bool, journal: Journal, - logFilesProvider: LogFilesProviding + logFilesProvider: LogFilesProviding, + faultyMLSRemovalKeysByDomain: [String: [String]] ) -> ZMUserSession? { let wireAPIBackendEnvironment = BackendEnvironment( url: environment.backendURL, @@ -140,7 +141,8 @@ open class AuthenticatedSessionFactory { userId: account.userIdentifier, minTLSVersion: minTLSVersion, journal: journal, - logFilesProvider: logFilesProvider + logFilesProvider: logFilesProvider, + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain ) let userSession = userSessionBuilder.build() diff --git a/wire-ios-sync-engine/Source/SessionManager/SessionManager.swift b/wire-ios-sync-engine/Source/SessionManager/SessionManager.swift index b9048f03ba2..37c3d45f72d 100644 --- a/wire-ios-sync-engine/Source/SessionManager/SessionManager.swift +++ b/wire-ios-sync-engine/Source/SessionManager/SessionManager.swift @@ -1010,7 +1010,8 @@ public final class SessionManager: NSObject, SessionManagerType { mediaManager: authenticatedSessionFactory.mediaManager, flowManager: authenticatedSessionFactory.flowManager, logFilesProvider: logFilesProvider, - isDeveloperModeEnabled: isDeveloperModeEnabled + isDeveloperModeEnabled: isDeveloperModeEnabled, + faultyMLSRemovalKeysByDomain: configuration.faultyMLSRemovalKeysByDomain ) let userSession = try await loader.load(newEnvironment: newEnvironment) @@ -1289,7 +1290,8 @@ public final class SessionManager: NSObject, SessionManagerType { sharedUserDefaults: sharedUserDefaults, isDeveloperModeEnabled: isDeveloperModeEnabled, journal: journal, - logFilesProvider: logFilesProvider + logFilesProvider: logFilesProvider, + faultyMLSRemovalKeysByDomain: configuration.faultyMLSRemovalKeysByDomain ) } diff --git a/wire-ios-sync-engine/Source/SessionManager/SessionManagerConfiguration.swift b/wire-ios-sync-engine/Source/SessionManager/SessionManagerConfiguration.swift index 2b2080ccf01..4290b025c56 100644 --- a/wire-ios-sync-engine/Source/SessionManager/SessionManagerConfiguration.swift +++ b/wire-ios-sync-engine/Source/SessionManager/SessionManagerConfiguration.swift @@ -77,6 +77,13 @@ public class SessionManagerConfiguration: NSObject, NSCopying, Codable { public var legacyAppLockConfig: AppLockController.LegacyConfig? + /// A dictionary mapping domains to hex encoded MLS removal keys that are considered faulty. + /// + /// If this is set, then any groups belonging to the specified domains with any of the specified keys will be reset. + /// Key: domain, Value: array of hex encoded faulty removal keys + + public var faultyMLSRemovalKeysByDomain: [String: [String]] + // MARK: - Init public init( @@ -88,7 +95,8 @@ public class SessionManagerConfiguration: NSObject, NSCopying, Codable { authenticateAfterReboot: Bool = false, failedPasswordThresholdBeforeWipe: Int? = nil, encryptionAtRestIsEnabledByDefault: Bool = false, - legacyAppLockConfig: AppLockController.LegacyConfig? = nil + legacyAppLockConfig: AppLockController.LegacyConfig? = nil, + faultyMLSRemovalKeysByDomain: [String: [String]] = [:] ) { self.wipeOnCookieInvalid = wipeOnCookieInvalid self.blacklistDownloadInterval = blacklistDownloadInterval @@ -99,6 +107,7 @@ public class SessionManagerConfiguration: NSObject, NSCopying, Codable { self.failedPasswordThresholdBeforeWipe = failedPasswordThresholdBeforeWipe self.encryptionAtRestEnabledByDefault = encryptionAtRestIsEnabledByDefault self.legacyAppLockConfig = legacyAppLockConfig + self.faultyMLSRemovalKeysByDomain = faultyMLSRemovalKeysByDomain } public required init(from decoder: Decoder) throws { @@ -124,6 +133,10 @@ public class SessionManagerConfiguration: NSObject, NSCopying, Codable { AppLockController.LegacyConfig.self, forKey: .legacyAppLockConfig ) + self.faultyMLSRemovalKeysByDomain = try container.decodeIfPresent( + [String: [String]].self, + forKey: .faultyMLSRemovalKeysByDomain + ) ?? [:] } // MARK: - Methods @@ -138,7 +151,8 @@ public class SessionManagerConfiguration: NSObject, NSCopying, Codable { authenticateAfterReboot: authenticateAfterReboot, failedPasswordThresholdBeforeWipe: failedPasswordThresholdBeforeWipe, encryptionAtRestIsEnabledByDefault: encryptionAtRestEnabledByDefault, - legacyAppLockConfig: legacyAppLockConfig + legacyAppLockConfig: legacyAppLockConfig, + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain ) } @@ -170,6 +184,7 @@ extension SessionManagerConfiguration { case failedPasswordThresholdBeforeWipe case encryptionAtRestEnabledByDefault case legacyAppLockConfig + case faultyMLSRemovalKeysByDomain } diff --git a/wire-ios-sync-engine/Source/SessionManager/UserSessionLoader.swift b/wire-ios-sync-engine/Source/SessionManager/UserSessionLoader.swift index 15d20c629d0..5c866734a5d 100644 --- a/wire-ios-sync-engine/Source/SessionManager/UserSessionLoader.swift +++ b/wire-ios-sync-engine/Source/SessionManager/UserSessionLoader.swift @@ -48,6 +48,7 @@ final class UserSessionLoader { private let accountID: UUID private let backendStore: BackendEnvironmentStore private let journal: Journal + private let faultyMLSRemovalKeysByDomain: [String: [String]] weak var delegate: UserSessionLoaderDelegate? @@ -65,7 +66,8 @@ final class UserSessionLoader { mediaManager: MediaManagerType, flowManager: FlowManagerType, logFilesProvider: LogFilesProviding, - isDeveloperModeEnabled: Bool + isDeveloperModeEnabled: Bool, + faultyMLSRemovalKeysByDomain: [String: [String]] ) throws { self.account = account self.accountManager = accountManager @@ -89,6 +91,7 @@ final class UserSessionLoader { userID: accountID, storage: sharedUserDefaults ) + self.faultyMLSRemovalKeysByDomain = faultyMLSRemovalKeysByDomain } @MainActor @@ -549,7 +552,8 @@ final class UserSessionLoader { dependencies: dependencies, journal: journal, logFilesProvider: logFilesProvider, - cookieStorage: cookieStorage + cookieStorage: cookieStorage, + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain ) userSession.setup( diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift index 02c9186f096..a9b41ebb2b9 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift @@ -460,7 +460,8 @@ public final class ZMUserSession: NSObject { dependencies: UserSessionDependencies, journal: Journal, logFilesProvider: LogFilesProviding, - cookieStorage: any CookieStorageProtocol + cookieStorage: any CookieStorageProtocol, + faultyMLSRemovalKeysByDomain: [String: [String]] ) { self.application = application self.currentAppVersion = currentAppVersion @@ -512,7 +513,8 @@ public final class ZMUserSession: NSObject { mlsService: mlsService, mlsDecryptionService: mlsService, proteusService: proteusService, - coreCryptoProvider: coreCryptoProvider + coreCryptoProvider: coreCryptoProvider, + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain ) self.conversationEventProcessor = ConversationEventProcessor( diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSessionBuilder.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSessionBuilder.swift index 0dcdf8d9212..d521c5b8902 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSessionBuilder.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSessionBuilder.swift @@ -57,6 +57,7 @@ struct ZMUserSessionBuilder { private var apiVersion: WireNetwork.APIVersion? private var journal: Journal? private var logFilesProvider: LogFilesProviding? + private var faultyMLSRemovalKeysByDomain: [String: [String]]? // MARK: - Initialize @@ -184,7 +185,8 @@ struct ZMUserSessionBuilder { dependencies: dependencies, journal: journal, logFilesProvider: logFilesProvider, - cookieStorage: cookieStorage + cookieStorage: cookieStorage, + faultyMLSRemovalKeysByDomain: faultyMLSRemovalKeysByDomain ?? [:] ) } @@ -212,7 +214,8 @@ struct ZMUserSessionBuilder { userId: UUID, minTLSVersion: String?, journal: Journal, - logFilesProvider: LogFilesProviding + logFilesProvider: LogFilesProviding, + faultyMLSRemovalKeysByDomain: [String: [String]] ) { // reused dependencies @@ -314,6 +317,7 @@ struct ZMUserSessionBuilder { self.wireAPIBackendEnvironment = wireAPIBackendEnvironment self.journal = journal self.logFilesProvider = logFilesProvider + self.faultyMLSRemovalKeysByDomain = faultyMLSRemovalKeysByDomain } // MARK: UserSesssionDependencies diff --git a/wire-ios-sync-engine/Tests/Source/SessionManager/APIMigrationManagerTests.swift b/wire-ios-sync-engine/Tests/Source/SessionManager/APIMigrationManagerTests.swift index 00b47ed2366..08a508efa09 100644 --- a/wire-ios-sync-engine/Tests/Source/SessionManager/APIMigrationManagerTests.swift +++ b/wire-ios-sync-engine/Tests/Source/SessionManager/APIMigrationManagerTests.swift @@ -327,7 +327,8 @@ final class APIMigrationManagerTests: MessagingTest { userId: userID, minTLSVersion: nil, journal: journal, - logFilesProvider: logFilesProvider + logFilesProvider: logFilesProvider, + faultyMLSRemovalKeysByDomain: [:] ) let userSession = builder.build() diff --git a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift index 1cdb126aa06..bf760c0af09 100644 --- a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift +++ b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift @@ -194,7 +194,8 @@ class ZMUserSessionTestsBase: MessagingTest { userId: coreDataStack.account.userIdentifier, minTLSVersion: nil, journal: journal, - logFilesProvider: logFilesProvider + logFilesProvider: logFilesProvider, + faultyMLSRemovalKeysByDomain: [:] ) let userSession = builder.build() diff --git a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests_NetworkState.swift b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests_NetworkState.swift index 08e25b25db3..405f03d0a38 100644 --- a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests_NetworkState.swift +++ b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests_NetworkState.swift @@ -84,7 +84,8 @@ final class ZMUserSessionTests_NetworkState: ZMUserSessionTestsBase { userId: userId, minTLSVersion: nil, journal: journal, - logFilesProvider: logFilesProvider + logFilesProvider: logFilesProvider, + faultyMLSRemovalKeysByDomain: [:] ) let testSession = builder.build() testSession.setup( diff --git a/wire-ios/Wire-iOS UnitTests/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModelTests.swift b/wire-ios/Wire-iOS UnitTests/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModelTests.swift index bce2d2eb5db..d7341df6801 100644 --- a/wire-ios/Wire-iOS UnitTests/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModelTests.swift +++ b/wire-ios/Wire-iOS UnitTests/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModelTests.swift @@ -27,7 +27,7 @@ final class DeveloperDebugActionsViewModelTests: XCTestCase { // when // then - XCTAssertEqual(viewModel.debugItems.count, 16) + XCTAssertEqual(viewModel.debugItems.count, 17) } // MARK: - Helpers diff --git a/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModel.swift b/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModel.swift index fab4e2e83ec..46ca9f71539 100644 --- a/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModel.swift +++ b/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DebugActions/DeveloperDebugActionsViewModel.swift @@ -87,6 +87,7 @@ final class DeveloperDebugActionsViewModel: ObservableObject { .init(title: "Invalidate all conversations", action: invalidateAllConversations), .init(title: "Set last app version migration", action: requestAppVersionInput), .init(title: "Initiate reset of first from top MLS", action: initiateResetBrokenMLSConversation), + .init(title: "Repair faulty removal key", action: initiateRepairRemovalKeys), .init(title: "Logout", action: logout) ] @@ -195,6 +196,31 @@ final class DeveloperDebugActionsViewModel: ObservableObject { } + private func initiateRepairRemovalKeys() { + guard let useCase = userSession?.clientSessionComponent?.repairFaultyRemovalKeysUsecase else { + WireLogger.mls.warn( + "unable to manually trigger to initiate repair removal keys because the usecase is not available", + attributes: .safePublic + ) + return + } + + Task { @MainActor in + WireLogger.mls.info( + "manual trigger to initiate repair removal keys", + attributes: .safePublic + ) + do { + try await useCase.invoke() + } catch { + WireLogger.mls.error( + "manual trigger to repair removal keys failed: \(String(describing: error))", + attributes: .safePublic + ) + } + } + } + func logout() { LogOutHelper(showLoading: {}, hideLoading: {}).logout() }