Skip to content
1 change: 1 addition & 0 deletions WireUI/Sources/WireLocators/Locators.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ public enum Locators {
case archive
case clearContent
case leaveConversation
case migrateToMLS
case moveToFolder
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//
// Wire
// Copyright (C) 2026 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 CoreData
import Foundation

Comment thread
Copilot marked this conversation as resolved.
public protocol MigrateConversationToMLSUseCaseProtocol {

/// Migrates one team group conversation from Proteus or mixed mode to MLS.
///
/// Calling this method for an MLS conversation succeeds without performing any work.
func invoke(
conversationID: QualifiedID,
syncContext: NSManagedObjectContext
) async throws

}

public final class MigrateConversationToMLSUseCase: MigrateConversationToMLSUseCaseProtocol {

public enum Failure: Error, Equatable {
case conversationNotFound
case unsupportedConversation
case missingMLSService
case missingMLSGroupID
}

private let actionsProvider: any MLSActionsProviderProtocol

public convenience init() {
self.init(actionsProvider: MLSActionsProvider())
}

init(actionsProvider: any MLSActionsProviderProtocol) {
self.actionsProvider = actionsProvider
}

public func invoke(
conversationID: QualifiedID,
syncContext: NSManagedObjectContext
) async throws {
let isSyncContext = await syncContext.perform { syncContext.zm_isSyncContext }
precondition(isSyncContext, "use case should only be accessed on the sync context")

switch try await messageProtocol(for: conversationID, in: syncContext) {
case .mls:
return

case .mixed:
try await finaliseMigration(conversationID: conversationID, syncContext: syncContext)

case .proteus:
try await startMigration(conversationID: conversationID, syncContext: syncContext)
try await finaliseMigration(conversationID: conversationID, syncContext: syncContext)
}
}

private func messageProtocol(
for conversationID: QualifiedID,
in context: NSManagedObjectContext
) async throws -> MessageProtocol {
try await context.perform {
guard let conversation = ZMConversation.fetch(
with: conversationID.uuid,
domain: conversationID.domain,
in: context
) else {
throw Failure.conversationNotFound
}

let selfUser = ZMUser.selfUser(in: context)
guard conversation.conversationType == .group,
let selfTeamIdentifier = selfUser.teamIdentifier,
conversation.teamRemoteIdentifier == selfTeamIdentifier
else {
throw Failure.unsupportedConversation
}

return conversation.messageProtocol
}
}

private func startMigration(
conversationID: QualifiedID,
syncContext: NSManagedObjectContext
) async throws {
try await updateConversationProtocol(
conversationID: conversationID,
to: .mixed,
syncContext: syncContext
)

let (mlsService, groupID, members) = try await syncContext.perform {
guard let conversation = ZMConversation.fetch(
with: conversationID.uuid,
domain: conversationID.domain,
in: syncContext
) else {
throw Failure.conversationNotFound
}

guard let mlsService = syncContext.mlsService else {
throw Failure.missingMLSService
}

guard let groupID = conversation.mlsGroupID else {
throw Failure.missingMLSGroupID
}

let members = conversation.localParticipants.map {
MLSUser(from: $0, localDomain: mlsService.localDomain)
}

return (mlsService, groupID, members)
}

_ = try await mlsService.establishGroup(
for: groupID,
with: members,
removalKeys: nil
)
}

private func finaliseMigration(
conversationID: QualifiedID,
syncContext: NSManagedObjectContext
) async throws {
let (mlsService, groupID) = try await syncContext.perform {
guard let conversation = ZMConversation.fetch(
with: conversationID.uuid,
domain: conversationID.domain,
in: syncContext
) else {
throw Failure.conversationNotFound
}

guard let mlsService = syncContext.mlsService else {
throw Failure.missingMLSService
}

guard let groupID = conversation.mlsGroupID else {
throw Failure.missingMLSGroupID
}

return (mlsService, groupID)
}

if try await !mlsService.conversationExists(groupID: groupID) {
try await mlsService.joinGroup(with: groupID)
}

try await updateConversationProtocol(
conversationID: conversationID,
to: .mls,
syncContext: syncContext
)
}

private func updateConversationProtocol(
conversationID: QualifiedID,
to messageProtocol: MessageProtocol,
syncContext: NSManagedObjectContext
) async throws {
try await actionsProvider.updateConversationProtocol(
qualifiedID: conversationID,
messageProtocol: messageProtocol,
context: syncContext.notificationContext
)

try await actionsProvider.syncConversation(
qualifiedID: conversationID,
context: syncContext.notificationContext
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//
// Wire
// Copyright (C) 2026 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 XCTest
@testable import WireDataModel
@testable import WireDataModelSupport

final class MigrateConversationToMLSUseCaseTests: ZMBaseManagedObjectTest {

private enum TestFailure: Error, Equatable {
case groupEstablishment
}

private var sut: MigrateConversationToMLSUseCase!
private var actionsProvider: MockMLSActionsProviderProtocol!
private var mlsService: MockMLSServiceInterface!

override func setUp() {
super.setUp()

actionsProvider = MockMLSActionsProviderProtocol()
mlsService = MockMLSServiceInterface()
mlsService.underlyingLocalDomain = "example.com"
mlsService.conversationExistsGroupID_MockValue = true
mlsService.joinGroupWith_MockMethod = { _ in }
mlsService.establishGroupForWithRemovalKeys_MockValue = .MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519

actionsProvider.updateConversationProtocolQualifiedIDMessageProtocolContext_MockMethod = { _, _, _ in }
actionsProvider.syncConversationQualifiedIDContext_MockMethod = { _, _ in }

syncMOC.performAndWait {
syncMOC.mlsService = mlsService
}

sut = MigrateConversationToMLSUseCase(actionsProvider: actionsProvider)
}

override func tearDown() {
sut = nil
actionsProvider = nil
mlsService = nil
super.tearDown()
}

func testInvoke_MLSConversation_DoesNothing() async throws {
let conversationID = await createConversation(messageProtocol: .mls)

try await sut.invoke(conversationID: conversationID, syncContext: syncMOC)

XCTAssertTrue(actionsProvider.updateConversationProtocolQualifiedIDMessageProtocolContext_Invocations.isEmpty)
XCTAssertTrue(actionsProvider.syncConversationQualifiedIDContext_Invocations.isEmpty)
XCTAssertTrue(mlsService.establishGroupForWithRemovalKeys_Invocations.isEmpty)
XCTAssertTrue(mlsService.conversationExistsGroupID_Invocations.isEmpty)
}

func testInvoke_MixedConversation_FinalisesToMLS() async throws {
let groupID = MLSGroupID.random()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue, but thinks it may be safe to ignore.
The App uses an insecure Random Number Generator.

Why this might be safe to ignore:

This match is in a unit test file, where the random value is only used to create test group IDs and not for production security behavior. The rule likely overmatched a custom .random() helper name without showing that it uses an insecure RNG or that any attacker-controlled input or security-sensitive randomness is involved.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by ios_insecure_random_no_generator.

You can view more details about this finding in the Semgrep AppSec Platform.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/fp this is used in a test, safe to ignore

let conversationID = await createConversation(messageProtocol: .mixed, groupID: groupID)

try await sut.invoke(conversationID: conversationID, syncContext: syncMOC)

XCTAssertEqual(mlsService.conversationExistsGroupID_Invocations, [groupID])
XCTAssertTrue(mlsService.joinGroupWith_Invocations.isEmpty)
XCTAssertEqual(updatedProtocols, [.mls])
XCTAssertEqual(actionsProvider.syncConversationQualifiedIDContext_Invocations.count, 1)
}

func testInvoke_MixedConversation_JoinsMissingGroupBeforeFinalising() async throws {
let groupID = MLSGroupID.random()
Comment thread
netbe marked this conversation as resolved.
let conversationID = await createConversation(messageProtocol: .mixed, groupID: groupID)
mlsService.conversationExistsGroupID_MockValue = false

try await sut.invoke(conversationID: conversationID, syncContext: syncMOC)

XCTAssertEqual(mlsService.joinGroupWith_Invocations, [groupID])
XCTAssertEqual(updatedProtocols, [.mls])
}

func testInvoke_ProteusConversation_MigratesAndFinalisesToMLS() async throws {
let groupID = MLSGroupID.random()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue, but thinks it may be safe to ignore.
The App uses an insecure Random Number Generator.

Why this might be safe to ignore:

This match is in a unit test file, where a random group ID is being generated as test data rather than for a security-sensitive production use. The rule appears to have matched a custom .random() helper by name, not evidence that an insecure RNG API is actually being used here.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by ios_insecure_random_no_generator.

You can view more details about this finding in the Semgrep AppSec Platform.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/fp this is used in a test, safe to ignore

let conversationID = await createConversation(messageProtocol: .proteus, groupID: groupID)

try await sut.invoke(conversationID: conversationID, syncContext: syncMOC)

XCTAssertEqual(updatedProtocols, [.mixed, .mls])
XCTAssertEqual(actionsProvider.syncConversationQualifiedIDContext_Invocations.count, 2)
XCTAssertEqual(mlsService.establishGroupForWithRemovalKeys_Invocations.count, 1)
XCTAssertEqual(mlsService.establishGroupForWithRemovalKeys_Invocations.first?.groupID, groupID)
XCTAssertEqual(mlsService.conversationExistsGroupID_Invocations, [groupID])
}

func testInvoke_ProteusConversation_WhenMigrationFails_DoesNotFinalise() async throws {
let conversationID = await createConversation(messageProtocol: .proteus)
mlsService.establishGroupForWithRemovalKeys_MockError = TestFailure.groupEstablishment

await XCTAssertThrowsErrorAsync(
TestFailure.groupEstablishment,
when: {
try await self.sut.invoke(
conversationID: conversationID,
syncContext: self.syncMOC
)
}
)

XCTAssertEqual(updatedProtocols, [.mixed])
XCTAssertEqual(actionsProvider.syncConversationQualifiedIDContext_Invocations.count, 1)
XCTAssertTrue(mlsService.conversationExistsGroupID_Invocations.isEmpty)
}

func testInvoke_OneOnOneConversation_ThrowsUnsupportedConversation() async throws {
let conversationID = await createConversation(messageProtocol: .proteus, conversationType: .oneOnOne)

await XCTAssertThrowsErrorAsync(
MigrateConversationToMLSUseCase.Failure.unsupportedConversation,
when: {
try await self.sut.invoke(
conversationID: conversationID,
syncContext: self.syncMOC
)
}
)
}

func testInvoke_NonTeamGroupConversation_ThrowsUnsupportedConversation() async throws {
let conversationID = await createConversation(messageProtocol: .proteus, belongsToTeam: false)

await XCTAssertThrowsErrorAsync(
MigrateConversationToMLSUseCase.Failure.unsupportedConversation,
when: {
try await self.sut.invoke(
conversationID: conversationID,
syncContext: self.syncMOC
)
}
)
}

func testInvoke_MissingConversation_ThrowsConversationNotFound() async throws {
await XCTAssertThrowsErrorAsync(
MigrateConversationToMLSUseCase.Failure.conversationNotFound,
when: {
try await self.sut.invoke(
conversationID: .init(uuid: .create(), domain: "example.com"),
syncContext: self.syncMOC
)
}
)
}

private var updatedProtocols: [MessageProtocol] {
actionsProvider.updateConversationProtocolQualifiedIDMessageProtocolContext_Invocations.map(\.messageProtocol)
}

private func createConversation(
messageProtocol: MessageProtocol,
conversationType: ZMConversationType = .group,
groupID: MLSGroupID = .random(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue, but thinks it may be safe to ignore.
The App uses an insecure Random Number Generator.

Why this might be safe to ignore:

This match is in a unit test helper, not production code, and the random value is only used to populate a test conversation group ID. There is no security-sensitive use like key generation, token creation, or session handling here, so fixing it would not meaningfully improve application security.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by ios_insecure_random_no_generator.

You can view more details about this finding in the Semgrep AppSec Platform.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/fp this is used in a test, safe to ignore

belongsToTeam: Bool = true
) async -> QualifiedID {
await syncMOC.perform {
let selfUser = ZMUser.selfUser(in: self.syncMOC)
selfUser.teamIdentifier = belongsToTeam ? .create() : nil
selfUser.domain = "example.com"

let conversation = ZMConversation.insertNewObject(in: self.syncMOC)
conversation.remoteIdentifier = .create()
conversation.domain = "example.com"
conversation.teamRemoteIdentifier = selfUser.teamIdentifier
conversation.conversationType = conversationType
conversation.messageProtocol = messageProtocol
conversation.mlsGroupID = groupID

return conversation.qualifiedID!
}
}

}
Loading
Loading