From 44303bdee78aca15e71e3fddd1620286fad09db1 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Fri, 10 Oct 2025 16:44:26 +0200 Subject: [PATCH] Add support for reading accounts from Element Classic. --- ElementX.xcodeproj/project.pbxproj | 38 +++ .../Sources/Application/AppCoordinator.swift | 16 + ElementX/Sources/Other/InfoPlistReader.swift | 23 ++ .../View/AuthenticationStartScreen.swift | 3 +- .../AuthenticationService.swift | 21 +- .../ClassicApp/ClassicAppAES.swift | 78 +++++ .../ClassicApp/ClassicAppAccountManager.swift | 199 +++++++++++ .../ClassicApp/ClassicAppMXAccount.swift | 315 ++++++++++++++++++ .../ClassicApp/ClassicAppManager.swift | 62 ++++ .../SupportingFiles/ElementX.entitlements | 2 + ElementX/SupportingFiles/Info.plist | 6 + ElementX/SupportingFiles/target.yml | 5 + .../Sources/AuthenticationServiceTests.swift | 1 + ...henticationStartScreenViewModelTests.swift | 1 + .../Sources/LoginScreenViewModelTests.swift | 1 + ...rverConfirmationScreenViewModelTests.swift | 1 + .../ServerSelectionScreenViewModelTests.swift | 1 + app.yml | 5 + 18 files changed, 775 insertions(+), 3 deletions(-) create mode 100644 ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAES.swift create mode 100644 ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift create mode 100644 ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift create mode 100644 ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 1d555875d..2aa05693e 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -264,6 +264,7 @@ 2BC579CB5CE90CFE07CA0955 /* EditRoomAddressScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41656BC6267D55C56A2AAC08 /* EditRoomAddressScreenCoordinator.swift */; }; 2BEDEA4851E1DF901779362C /* AdvancedSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 922E498EB74CF6F5CC236F81 /* AdvancedSettingsScreenModels.swift */; }; 2BFA4C6D5B3D327B02C66AB0 /* TimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4216C12C0369A8AB059EDE9 /* TimelineController.swift */; }; + 2C289BECCCBEDBA48DC9AC67 /* ClassicAppMXAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */; }; 2C4C750D0039AFABDF24236C /* TemplateScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */; }; 2C5E832434EE94E21AB3B238 /* EmojiPickerScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3EAE3E9D5EF4A6D5D9C6CFD /* EmojiPickerScreenViewModel.swift */; }; 2CA61BB208CD82EBDB58CD13 /* VideoRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0CBEAB5F796BEFBAF7BB6A /* VideoRoomTimelineView.swift */; }; @@ -1323,6 +1324,7 @@ E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */; }; E8AE3BE5C5D2E6C91751A2A6 /* StateMachineFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73285743189AE4498A51DD /* StateMachineFactory.swift */; }; E8B290CBB7E5FF5E3C1B6124 /* KnockRequestsListEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */; }; + E8BBCFF3B1380F56C73690BC /* ClassicAppManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */; }; E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; }; E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; }; E9347F56CF0683208F4D9249 /* RoomNotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A9B5225D0881CEFA2CF7C9 /* RoomNotificationSettingsScreenViewModel.swift */; }; @@ -1356,6 +1358,7 @@ EDF8919F15DE0FF00EF99E70 /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5567A7EF6F2AB9473236F6 /* DocumentPicker.swift */; }; EE17B7154DCA50677D931A94 /* NavigationTabCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B67DC84B42D86035FCFF6F8 /* NavigationTabCoordinatorTests.swift */; }; EE4E2C1922BBF5169E213555 /* PillAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B53D6C5C0D14B04D3AB3F6E /* PillAttachmentViewProvider.swift */; }; + EE4F6D6F54B8F09F10CA66BE /* ClassicAppAES.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384A744714571BAF138C6B86 /* ClassicAppAES.swift */; }; EE56238683BC3ECA9BA00684 /* GlobalSearchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */; }; EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; }; EEAE954289DE813A61656AE0 /* LayoutDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */; }; @@ -1406,6 +1409,7 @@ F4D5A2A8304ED61621BF02D4 /* test_audio.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 66B96842BF5F8ACA1AC84C55 /* test_audio.mp3 */; }; F519DE17A3A0F760307B2E6D /* InviteUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */; }; F54E2D6CAD96E1AC15BC526F /* MessageForwardingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */; }; + F58BF3BD3233A03F013816E4 /* ClassicAppAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */; }; F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */; }; F656F92A63D3DC1978D79427 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 290FDEDA4D764B9F7EBE55A9 /* Algorithms */; }; F65F3909769E7C48B3309D9D /* RemoteSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D186A6DB8FAC5C9D0E4D61 /* RemoteSettingsHook.swift */; }; @@ -1872,11 +1876,13 @@ 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyMock.swift; sourceTree = ""; }; 38345442415E07A931197C55 /* AppLockScreenPINKeypad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenPINKeypad.swift; sourceTree = ""; }; 38354164AF59C5006CD05878 /* GlobalSearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModel.swift; sourceTree = ""; }; + 384A744714571BAF138C6B86 /* ClassicAppAES.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAES.swift; sourceTree = ""; }; 3865AD7B7249C939D7C69C33 /* CertificateValidatorHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateValidatorHook.swift; sourceTree = ""; }; 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreen.swift; sourceTree = ""; }; 3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenScreenModelProtocol.swift; sourceTree = ""; }; 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadableFrameModifier.swift; sourceTree = ""; }; 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerMock.swift; sourceTree = ""; }; + 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppManager.swift; sourceTree = ""; }; 3A12D3D8138F1B71AFA7C858 /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = ""; }; 3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitedRoomProxyMock.swift; sourceTree = ""; }; 3AB34956C87731AB094DB33A /* URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTests.swift; sourceTree = ""; }; @@ -2182,6 +2188,7 @@ 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingComposer.swift; sourceTree = ""; }; 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = archive.ar; path = libMatrixRustSDKMocks.a; sourceTree = BUILT_PRODUCTS_DIR; }; 73FEE625AB52042049DB9268 /* ThreadTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenCoordinator.swift; sourceTree = ""; }; + 74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppMXAccount.swift; sourceTree = ""; }; 7447C0AD7EF302CD027D6230 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SAS.strings; sourceTree = ""; }; 7463464054DDF194C54F0B04 /* LogViewerScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenViewModelProtocol.swift; sourceTree = ""; }; 74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenCoordinator.swift; sourceTree = ""; }; @@ -2393,6 +2400,7 @@ 989FC684408B31A677F5538B /* CompletionSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionView.swift; sourceTree = ""; }; 98ABC939BC8F08CA3E967D6C /* JoinCallButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinCallButton.swift; sourceTree = ""; }; 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemThreadSummary.swift; sourceTree = ""; }; + 98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAccountManager.swift; sourceTree = ""; }; 997BF045585AF6DB2EBC5755 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinator.swift; sourceTree = ""; }; 9A028783CFFF861C5E44FFB1 /* BadgeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeLabel.swift; sourceTree = ""; }; @@ -3982,6 +3990,13 @@ path = SupportingFiles; sourceTree = ""; }; + 4001FC7CD65776AB3745245C /* ClassicAppAccountConfirmationScreen */ = { + isa = PBXGroup; + children = ( + ); + path = ClassicAppAccountConfirmationScreen; + sourceTree = ""; + }; 4044C040B64B9F077298C947 /* View */ = { isa = PBXGroup; children = ( @@ -4300,6 +4315,17 @@ path = ReportRoomScreen; sourceTree = ""; }; + 4E126BB8215781BE1A126743 /* ClassicApp */ = { + isa = PBXGroup; + children = ( + 98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */, + 384A744714571BAF138C6B86 /* ClassicAppAES.swift */, + 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */, + 74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */, + ); + path = ClassicApp; + sourceTree = ""; + }; 4EC4EBBC4F6885775F198875 /* Sources */ = { isa = PBXGroup; children = ( @@ -5790,6 +5816,7 @@ F3A1AB5A84D843B6AC8D5F1E /* AuthenticationService.swift */, 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */, 007C16779FDCF10DA4F1A510 /* LinkNewDeviceService.swift */, + 4E126BB8215781BE1A126743 /* ClassicApp */, ); path = Authentication; sourceTree = ""; @@ -6591,6 +6618,7 @@ children = ( 92390F9FA98255440A6BF5F8 /* OIDCAuthenticationPresenter.swift */, 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */, + 4001FC7CD65776AB3745245C /* ClassicAppAccountConfirmationScreen */, 90F48FEF84016ED42A94BA24 /* LoginScreen */, BA1938A75D8C780F694CEB62 /* ServerConfirmationScreen */, 2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */, @@ -7984,6 +8012,10 @@ A076E0A9338FD2D950C3C4A1 /* ChatsSpaceFiltersScreenViewModelProtocol.swift in Sources */, 572474C7CA4B03FF0B5DF548 /* ChatsTabFlowCoordinator.swift in Sources */, BC5F94B10B40ABEC6046B473 /* ChatsTabFlowCoordinatorStateMachine.swift in Sources */, + EE4F6D6F54B8F09F10CA66BE /* ClassicAppAES.swift in Sources */, + F58BF3BD3233A03F013816E4 /* ClassicAppAccountManager.swift in Sources */, + 2C289BECCCBEDBA48DC9AC67 /* ClassicAppMXAccount.swift in Sources */, + E8BBCFF3B1380F56C73690BC /* ClassicAppManager.swift in Sources */, A52090A4FE0DB826578DFC03 /* Client.swift in Sources */, C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */, 87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */, @@ -9398,6 +9430,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector; + CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"; + CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -9468,6 +9503,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector; + CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"; + CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 82655baf2..71695c97a 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -605,8 +605,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private func startAuthentication() { let encryptionKeyProvider = EncryptionKeyProvider() + let classicAppManager = makeClassicAppManager() let authenticationService = AuthenticationService(userSessionStore: userSessionStore, encryptionKeyProvider: encryptionKeyProvider, + classicAppManager: classicAppManager, appSettings: appSettings, appHooks: appHooks) @@ -628,6 +630,19 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } } + private func makeClassicAppManager() -> ClassicAppManagerProtocol? { + guard let classicAppGroupIdentifier = InfoPlistReader.main.classicAppGroupIdentifier, + let classicAppKeychainServiceIdentifier = InfoPlistReader.main.classicAppKeychainServiceIdentifier, + let classicAppKeychainAccessGroupIdentifier = InfoPlistReader.main.classicAppKeychainAccessGroupIdentifier else { + MXLog.info("Classic App IDs not set, manager disabled.") + return nil + } + + return ClassicAppManager(classicAppGroupIdentifier: classicAppGroupIdentifier, + classicAppKeychainServiceIdentifier: classicAppKeychainServiceIdentifier, + classicAppKeychainAccessGroupIdentifier: classicAppKeychainAccessGroupIdentifier) + } + private func runPostSessionSetupTasks() async { guard let userSession, let userSessionFlowCoordinator else { fatalError("User session not setup") @@ -660,6 +675,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg let authenticationService = AuthenticationService(userSessionStore: userSessionStore, encryptionKeyProvider: EncryptionKeyProvider(), + classicAppManager: makeClassicAppManager(), appSettings: appSettings, appHooks: appHooks) _ = await authenticationService.configure(for: userSession.clientProxy.homeserver, flow: .login) diff --git a/ElementX/Sources/Other/InfoPlistReader.swift b/ElementX/Sources/Other/InfoPlistReader.swift index 8f3ec7446..c5ed0d51b 100644 --- a/ElementX/Sources/Other/InfoPlistReader.swift +++ b/ElementX/Sources/Other/InfoPlistReader.swift @@ -23,6 +23,10 @@ struct InfoPlistReader { static let bundleURLTypes = "CFBundleURLTypes" static let bundleURLName = "CFBundleURLName" static let bundleURLSchemes = "CFBundleURLSchemes" + + static let classicAppGroupIdentifier = "classicAppGroupIdentifier" + static let classicAppKeychainServiceIdentifier = "classicAppKeychainServiceIdentifier" + static let classicAppKeychainAccessGroupIdentifier = "classicAppKeychainAccessGroupIdentifier" } private enum Values { @@ -115,8 +119,23 @@ struct InfoPlistReader { return utType.lowercased() } + // MARK: - Sign in with Classic app + + var classicAppGroupIdentifier: String? { + infoPlistValue(forKey: Keys.classicAppGroupIdentifier) + } + + var classicAppKeychainServiceIdentifier: String? { + infoPlistValue(forKey: Keys.classicAppKeychainServiceIdentifier) + } + + var classicAppKeychainAccessGroupIdentifier: String? { + infoPlistValue(forKey: Keys.classicAppKeychainAccessGroupIdentifier) + } + // MARK: - Private + @_disfavoredOverload // Make sure optional types default to the optional version below. private func infoPlistValue(forKey key: String) -> T { guard let result = bundle.object(forInfoDictionaryKey: key) as? T else { fatalError("Add \(key) into your target's Info.plst") @@ -124,6 +143,10 @@ struct InfoPlistReader { return result } + private func infoPlistValue(forKey key: String) -> T? { + bundle.object(forInfoDictionaryKey: key) as? T + } + private func customSchemeForName(_ name: String) -> String { let urlTypes: [[String: Any]] = infoPlistValue(forKey: Keys.bundleURLTypes) diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift index 36046fc13..23079148d 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift @@ -13,7 +13,7 @@ import SwiftUI struct AuthenticationStartScreen: View { @Environment(\.verticalSizeClass) private var verticalSizeClass - let context: AuthenticationStartScreenViewModel.Context + @Bindable var context: AuthenticationStartScreenViewModel.Context var body: some View { GeometryReader { geometry in @@ -51,6 +51,7 @@ struct AuthenticationStartScreen: View { .background { AuthenticationStartScreenBackgroundImage() } + .alert(item: $context.alertInfo) .introspect(.window, on: .supportedVersions) { window in context.send(viewAction: .updateWindow(window)) } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index 0edff463f..0a0927e15 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -15,8 +15,9 @@ class AuthenticationService: AuthenticationServiceProtocol { private var sessionDirectories: SessionDirectories private let passphrase: String - private let clientFactory: AuthenticationClientFactoryProtocol private let userSessionStore: UserSessionStoreProtocol + private let classicAppManager: ClassicAppManagerProtocol? + private let clientFactory: AuthenticationClientFactoryProtocol private let appSettings: AppSettings private let appHooks: AppHooks @@ -29,16 +30,31 @@ class AuthenticationService: AuthenticationServiceProtocol { init(userSessionStore: UserSessionStoreProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol, + classicAppManager: ClassicAppManagerProtocol?, clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(), appSettings: AppSettings, appHooks: AppHooks) { sessionDirectories = .init() passphrase = encryptionKeyProvider.generateKey().base64EncodedString() - self.clientFactory = clientFactory + self.userSessionStore = userSessionStore + self.classicAppManager = classicAppManager + self.clientFactory = clientFactory self.appSettings = appSettings self.appHooks = appHooks + do { + if let classicAppManager { + // Just let the app manager log the detected account for now. + _ = try classicAppManager.loadAccounts() + } else { + MXLog.info("Classic App not configured, skipping loadAccounts.") + } + } catch { + // This should show an alert: "We have detected an older version of Element Classic, but no bueno!" + MXLog.error("Failed loading accounts from the Classic app: \(error)") + } + // When updating these, don't forget to update the reset method too. homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) flow = .login @@ -277,6 +293,7 @@ extension AuthenticationService { static var mock: AuthenticationService { AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), + classicAppManager: nil, clientFactory: AuthenticationClientFactoryMock(configuration: .init()), appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAES.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAES.swift new file mode 100644 index 000000000..34a716b33 --- /dev/null +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAES.swift @@ -0,0 +1,78 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import CommonCrypto +import Foundation + +enum ClassicAppAES { + enum Error: Swift.Error { + case cannotInitializeCryptor + case decryptionFailed(CCCryptorStatus) + } + + /// Decrypt data using AES-256 in CTR mode. + /// - Parameters: + /// - data: The data to decrypt + /// - aesKey: The AES decryption key (256-bit) + /// - iv: The initialization vector + /// - Returns: The decrypted data, or nil if decryption fails + /// - Throws: An error if decryption fails + static func decrypt(_ data: Data, aesKey: Data, iv: Data) throws -> Data { + var cryptor: CCCryptorRef? + var status: CCCryptorStatus + + // Create the cryptor + status = iv.withUnsafeBytes { ivBytes in + aesKey.withUnsafeBytes { keyBytes in + CCCryptorCreateWithMode(CCOperation(kCCDecrypt), + CCMode(kCCModeCTR), + CCAlgorithm(kCCAlgorithmAES), + CCPadding(ccNoPadding), + ivBytes.baseAddress, + keyBytes.baseAddress, + kCCKeySizeAES256, + nil, + 0, + 0, + CCModeOptions(kCCModeOptionCTR_BE), + &cryptor) + } + } + + guard status == kCCSuccess, let cryptor else { + MXLog.error("Failed to create cryptor: \(status)") + throw Error.cannotInitializeCryptor + } + + // Get the output buffer size + let bufferLength = CCCryptorGetOutputLength(cryptor, data.count, false) + var buffer = Data(count: bufferLength) + + var outLength = 0 + status = data.withUnsafeBytes { dataBytes in + let count = buffer.count + return buffer.withUnsafeMutableBytes { bufferBytes in + CCCryptorUpdate(cryptor, + dataBytes.baseAddress, + data.count, + bufferBytes.baseAddress, + count, + &outLength) + } + } + + let releaseStatus = CCCryptorRelease(cryptor) + status = (status == kCCSuccess && releaseStatus == kCCSuccess) ? CCCryptorStatus(kCCSuccess) : status + + guard status == kCCSuccess else { + MXLog.error("Decryption failed: \(status)") + throw Error.decryptionFailed(status) + } + + return buffer + } +} diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift new file mode 100644 index 000000000..8db50ca58 --- /dev/null +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift @@ -0,0 +1,199 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +class ClassicAppAccountManager { + static let matrixKitFolder = "MatrixKit" + static let kMXKAccountsKey = "accountsV2" + static let kMXFileStoreFolder = "MXFileStore" + static let kMXFileStoreUsersFolder = "users" + static let cryptoStoreFolder = "MXCryptoStore" + + let cacheFolder: URL + let iv: Data + let aesKey: Data + + private(set) var accounts: [ClassicAppMXAccount] = [] + private var users: [String: ClassicAppMXUser] = [:] + + var activeAccounts: [ClassicAppAccount] { + accounts + .filter { !$0.isDisabled && !$0.isSoftLogout } + .compactMap(activeAccount) + } + + init(cacheFolder: URL, iv: Data, aesKey: Data) { + self.cacheFolder = cacheFolder + self.iv = iv + self.aesKey = aesKey + } + + /// Return the path of the file containing stored MXAccounts array + func accountFile() -> URL { + cacheFolder.appending(component: Self.matrixKitFolder).appending(component: Self.kMXKAccountsKey) + } + + func loadAccounts() { + MXLog.info("Loading accounts from Classic app.") + let accountFile = accountFile() + if FileManager.default.fileExists(atPath: accountFile.path(percentEncoded: false)) { + let startDate = Date() + + do { + let fileContent = try Data(contentsOf: accountFile, options: [.alwaysMapped, .uncached]) + + // Decrypt data if encryption method is provided + let unciphered = try ClassicAppAES.decrypt(fileContent, aesKey: aesKey, iv: iv) + let decoder = NSKeyedUnarchiver(forReadingWith: unciphered) + decoder.setClass(ClassicAppMXAccount.self, forClassName: "MXKAccount") + decoder.setClass(ClassicAppMXThirdPartyIdentifier.self, forClassName: "MXThirdPartyIdentifier") + decoder.setClass(ClassicAppMXDevice.self, forClassName: "MXDevice") + + guard let accounts = decoder.decodeObject(forKey: "mxAccounts") as? [ClassicAppMXAccount] else { + MXLog.error("Failed to decode accounts.") + return + } + + self.accounts = accounts + + MXLog.info("[MXKAccountManager] loadAccounts. \(accounts.count) accounts loaded in \(Date().timeIntervalSince(startDate) * 1000)ms") + } catch { + MXLog.error("Failed to load account file: \(error)") + } + + for account in activeAccounts { + if let user = loadUsers([account.userID], forAccount: account.userID).first { + users[user.userID] = user + } + } + } + + if accounts.isEmpty { + MXLog.info("[MXKAccountManager] loadAccounts. No accounts") + } + } + + /// From `MXCryptoMachineStore` + func cryptoStoreURL(for userID: String) -> URL { + cacheFolder.appending(component: Self.cryptoStoreFolder).appending(component: userID) + } + + var fileStorePath: URL { + cacheFolder.appending(component: Self.kMXFileStoreFolder) + } + + func storePath(for userID: String) -> URL { + fileStorePath.appending(component: userID) + } + + /// This store contains all of the users known to the specific user ID. + func storeUsersPath(for userID: String) -> URL { + storePath(for: userID).appending(component: Self.kMXFileStoreUsersFolder) + } + + func loadUsers(_ userIDs: [String], forAccount accountUserID: String) -> [ClassicAppMXUser] { + // Determine which groups to load based on userIds + var groups: [String: [String]] = [:] + for userID in userIDs { + let groupID = String(userID.hash % 100) + + if groups[groupID] != nil { + groups[groupID]?.append(userID) + } else { + groups[groupID] = [userID] + } + } + + let usersFolder = storeUsersPath(for: accountUserID) + + var loadedUsers: [ClassicAppMXUser] = [] + for group in groups.keys { + autoreleasepool { + let groupFile = usersFolder.appendingPathComponent(group) + + // Load stored users in this group + do { + let fileContent = try Data(contentsOf: groupFile) + + let decoder = NSKeyedUnarchiver(forReadingWith: fileContent) + decoder.setClass(ClassicAppMXUser.self, forClassName: "MXUser") + decoder.setClass(ClassicAppMXUser.self, forClassName: "MXMyUser") + + if let groupUsers = decoder.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? [String: ClassicAppMXUser] { + let usersToLoad = Set(groups[group] ?? []) + for user in groupUsers.values where usersToLoad.contains(user.userID) { + loadedUsers.append(user) + } + } + } catch { + MXLog.warning("[MXFileStore] Warning: MXFileRoomStore file for users group \(group) has been corrupted") + } + } + } + + return loadedUsers + } + + private func activeAccount(mxAccount: ClassicAppMXAccount) -> ClassicAppAccount? { + guard let userID = mxAccount.credentials.userID, let serverName = serverName(for: userID) else { + return nil + } + + return ClassicAppAccount(userID: userID, + displayName: users[userID]?.displayName, + serverName: serverName, + cryptoStoreURL: cryptoStoreURL(for: userID)) + } + + /// The server name extracted from the user's ID. + private func serverName(for userID: String) -> String? { + #warning("Expose a serverName method for this from the SDK?") + let components = userID.components(separatedBy: ":") + guard components.count > 1 else { return nil } + return components[1] // Directly use [1] as .last may be the port number. + } +} + +// MARK: - Probably not needed + +private extension ClassicAppAccountManager { + func loadUsers(forAccount accountUserID: String) { + let startDate = Date() + var users: [String: ClassicAppMXUser] = [:] + + // Load all users which are distributed in several files + let storeUsersPath = storeUsersPath(for: accountUserID) + let groups = try? FileManager.default.contentsOfDirectory(atPath: storeUsersPath.path(percentEncoded: false)) + + if let groups { + for group in groups { + let groupFile = storeUsersPath.appending(path: group) + + // Load stored users in this group + do { + let fileContent = try Data(contentsOf: groupFile) + + let decoder = NSKeyedUnarchiver(forReadingWith: fileContent) + decoder.setClass(ClassicAppMXUser.self, forClassName: "MXUser") + decoder.setClass(ClassicAppMXUser.self, forClassName: "MXMyUser") + + if let groupUsers = decoder.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? [String: ClassicAppMXUser] { + // Append them + users.merge(groupUsers) { _, new in new } + } + } catch { + MXLog.error("Failed to load users from group \(group): \(error)") + } + } + } + + MXLog.debug("[MXFileStore] Loaded \(users.count) MXUsers in \(Date().timeIntervalSince(startDate) * 1000)ms") + + self.users = users + } +} diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift new file mode 100644 index 000000000..76ae8d8ef --- /dev/null +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift @@ -0,0 +1,315 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +struct ClassicAppAccount { + let userID: String + let displayName: String? + let serverName: String + let cryptoStoreURL: URL +} + +// MARK: NSCoding Types + +class ClassicAppMXAccount: NSObject, NSCoding { + /// The account's credentials: homeserver, access token, user ID. + private(set) var credentials: Credentials + /// The identity server URL. + var identityServerURL: String + /// The antivirus server URL, if any (nil by default). + /// Set a non-null url to configure the antivirus scanner use. + var antivirusServerURL: String? + /// The Push Gateway URL used to send event notifications to (nil by default). + /// This URL should be over HTTPS and never over HTTP. + var pushGatewayURL: String? + /// The 3PIDs linked to this account. + /// [self load3PIDs] must be called to update the property. + private(set) var threePIDs: [ClassicAppMXThirdPartyIdentifier]? + /// The account user's device. + /// [self loadDeviceInformation] must be called to update the property. + private(set) var device: ClassicAppMXDevice? + /// Transient information storage. + private(set) var others = NSMutableDictionary() + /// Flag to indicate that an APNS pusher has been set on the homeserver for this device. + private(set) var hasPusherForPushNotifications = false + + /// The Push notification activity (based on PushKit) for this account. + /// YES when Push is turned on (locally available and enabled homeserver side). + var isPushKitNotificationActive: Bool { + // This would typically have custom getter logic + hasPusherForPushKitNotifications + } + + /// Flag to indicate that a PushKit pusher has been set on the homeserver for this device. + private(set) var hasPusherForPushKitNotifications = false + /// Enable In-App notifications based on Remote notifications rules. + /// NO by default. + var enableInAppNotifications = false + /// Disable the account without logging out (NO by default). + /// + /// A matrix session is automatically opened for the account when this property is toggled from YES to NO. + /// The session is closed when this property is set to YES. + var isDisabled = false + /// Flag indicating if the end user has been warned about encryption and its limitations. + var isWarnedAboutEncryption = false + + /// Flag to indicate if the account has been logged out by the homeserver admin. + private(set) var isSoftLogout = false + + // MARK: NSCoding + + enum Keys { + static let homeserverURL = "homeserverurl" // String? + static let userID = "userid" // String? + static let accessToken = "accesstoken" // String? + static let accessTokenExpiresAt = "accessTokenExpiresAt" // UInt64 + static let refreshToken = "refreshToken" // String? + static let identityServerURL = "identityserverurl" // String? + static let identityServerAccessToken = "identityserveraccesstoken" // String? + static let deviceID = "deviceId" // String? + static let allowedCertificate = "allowedCertificate" // Data? + static let threePIDs = "threePIDs" // [MXThirdPartyIdentifier]? + static let device = "device" // MXDevice? + static let antivirusServerURL = "antivirusserverurl" // String? + static let pushGatewayURL = "pushgatewayurl" // String? + static let hasPusherForPushNotifications = "_enablePushNotifications" // Bool + static let hasPusherForPushKitNotifications = "enablePushKitNotifications" // Bool + static let enableInAppNotifications = "enableInAppNotifications" // Bool + static let isDisabled = "disabled" // Bool + static let isSoftLogout = "isSoftLogout" // Bool + static let isWarnedAboutEncryption = "warnedAboutEncryption" // Bool + static let others = "others" // NSMutableDictionary + } + + required init?(coder: NSCoder) { + let homeserverURL = coder.decodeObject(forKey: Keys.homeserverURL) as? String + let userID = coder.decodeObject(forKey: Keys.userID) as? String + let accessToken = coder.decodeObject(forKey: Keys.accessToken) as? String + + credentials = Credentials(homeserver: homeserverURL, + userID: userID, + accessToken: accessToken) + + credentials.accessTokenExpiresAt = UInt64(coder.decodeInt64(forKey: Keys.accessTokenExpiresAt)) + credentials.refreshToken = coder.decodeObject(forKey: Keys.refreshToken) as? String + credentials.identityServer = coder.decodeObject(forKey: Keys.identityServerURL) as? String + credentials.identityServerAccessToken = coder.decodeObject(forKey: Keys.identityServerAccessToken) as? String + credentials.deviceID = coder.decodeObject(forKey: Keys.deviceID) as? String + credentials.allowedCertificate = coder.decodeObject(forKey: Keys.allowedCertificate) as? Data + + identityServerURL = credentials.identityServer ?? "" + + super.init() + + if let threePIDs = coder.decodeObject(forKey: Keys.threePIDs) as? [ClassicAppMXThirdPartyIdentifier] { + self.threePIDs = threePIDs + } + + if let device = coder.decodeObject(forKey: Keys.device) as? ClassicAppMXDevice { + self.device = device + } + + if let antivirusServerURL = coder.decodeObject(forKey: Keys.antivirusServerURL) as? String { + self.antivirusServerURL = antivirusServerURL + } + + if let pushGatewayURL = coder.decodeObject(forKey: Keys.pushGatewayURL) as? String { + self.pushGatewayURL = pushGatewayURL + } + + hasPusherForPushNotifications = coder.decodeBool(forKey: Keys.hasPusherForPushNotifications) + hasPusherForPushKitNotifications = coder.decodeBool(forKey: Keys.hasPusherForPushKitNotifications) + enableInAppNotifications = coder.decodeBool(forKey: Keys.enableInAppNotifications) + + isDisabled = coder.decodeBool(forKey: Keys.isDisabled) + isSoftLogout = coder.decodeBool(forKey: Keys.isSoftLogout) + + isWarnedAboutEncryption = coder.decodeBool(forKey: Keys.isWarnedAboutEncryption) + + if let others = coder.decodeObject(forKey: Keys.others) as? NSMutableDictionary { + self.others = others + } + } + + func encode(with coder: NSCoder) { + fatalError("Not available") + } + + /// The `MXCredentials` struct contains credentials to communicate with the Matrix + /// Client-Server API. + struct Credentials { + /// The homeserver url (ex: "https://matrix.org"). + var homeserver: String? + /// The identity server url (ex: "https://vector.im"). + var identityServer: String? + /// The obtained user ID. + var userID: String? + /// The access token to create a MXRestClient + var accessToken: String? + /// The timestamp in milliseconds for when the access token will expire + var accessTokenExpiresAt: UInt64 = 0 + /// The refresh token, which can be used to obtain new access tokens. (optional) + var refreshToken: String? + /// The access token to create a MXIdentityServerRestClient + var identityServerAccessToken: String? + /// The device ID. + var deviceID: String? + /// The server certificate trusted by the user (nil when the server is trusted by the device). + var allowedCertificate: Data? + /// The ignored server certificate (set when the user ignores a certificate change). + var ignoredCertificate: Data? + /// Additional data received during login process + var loginOthers: [String: Any]? + + init(homeserver: String?, userID: String?, accessToken: String?) { + self.homeserver = homeserver + self.userID = userID + self.accessToken = accessToken + } + } +} + +/// `MXThirdPartyIdentifier` represents the response to /account/3pid GET request. +class ClassicAppMXThirdPartyIdentifier: NSObject, NSCoding { + /// The medium of the third party identifier. + var medium: String + /// The third party identifier address. + var address: String + /// The timestamp in milliseconds when this 3PID has been validated. + var validatedAt: UInt64 + /// The timestamp in milliseconds when this 3PID has been added to the user account. + var addedAt: UInt64 + + // MARK: NSCoding + + enum Keys { + static let medium = "medium" // String + static let address = "address" // String + static let validatedAt = "validatedAt" // NSNumber?.uint64Value + static let addedAt = "addedAt" // NSNumber?.uint64Value + } + + required init?(coder aDecoder: NSCoder) { + guard let medium = aDecoder.decodeObject(forKey: Keys.medium) as? String, + let address = aDecoder.decodeObject(forKey: Keys.address) as? String else { + return nil + } + + self.medium = medium + self.address = address + + if let validatedAtNumber = aDecoder.decodeObject(forKey: Keys.validatedAt) as? NSNumber { + validatedAt = validatedAtNumber.uint64Value + } else { + validatedAt = 0 + } + + if let addedAtNumber = aDecoder.decodeObject(forKey: Keys.addedAt) as? NSNumber { + addedAt = addedAtNumber.uint64Value + } else { + addedAt = 0 + } + } + + func encode(with coder: NSCoder) { + fatalError("Not available") + } +} + +/// `MXDevice` represents a device of the current user. +class ClassicAppMXDevice: NSObject, NSCoding { + /// A unique identifier of the device. + var deviceID: String + /// The display name set by the user for this device. Absent if no name has been set. + var displayName: String? + /// The IP address where this device was last seen. (May be a few minutes out of date, for efficiency reasons). + var lastSeenIP: String? + /// The timestamp (in milliseconds since the unix epoch) when this devices was last seen. (May be a few minutes out of date, for efficiency reasons). + var lastSeenTimestamp: UInt64 + /// The latest recorded user agent for the device. + var lastSeenUserAgent: String? + + // MARK: NSCoding + + enum Keys { + static let deviceID = "device_id" // String + static let displayName = "display_name" // String? + static let lastSeenIP = "last_seen_ip" // String? + static let lastSeenTimestamp = "last_seen_ts" // NSNumber?.uint64Value + static let lastSeenUserAgent = "org.matrix.msc3852.last_seen_user_agent" // String? + } + + required init?(coder aDecoder: NSCoder) { + guard let deviceID = aDecoder.decodeObject(forKey: Keys.deviceID) as? String else { + return nil + } + + self.deviceID = deviceID + displayName = aDecoder.decodeObject(forKey: Keys.displayName) as? String + lastSeenIP = aDecoder.decodeObject(forKey: Keys.lastSeenIP) as? String + lastSeenTimestamp = (aDecoder.decodeObject(forKey: Keys.lastSeenTimestamp) as? NSNumber)?.uint64Value ?? 0 + lastSeenUserAgent = aDecoder.decodeObject(forKey: Keys.lastSeenUserAgent) as? String + } + + func encode(with coder: NSCoder) { + fatalError("Not available") + } +} + +/// `MXUser` represents a user in Matrix. +class ClassicAppMXUser: NSObject, NSCoding { + /// The user id. + private(set) var userID: String + /// The user display name. + var displayName: String? + /// The url of the user of the avatar. + var avatarURL: String? + /// The user status. + var statusMessage: String? + + /// Whether the user is currently active. + /// If YES, lastActiveAgo is an approximation and "Now" should be shown instead. + private(set) var currentlyActive = false + /// The time in milliseconds since epoch the last activity by the user has + /// been tracked by the home server. + var lastActiveLocalTimestamp: UInt64 = 0 + /// Only when event.originServerTs > latestUpdateTS, we change displayname and avatarUrl. + var latestUpdateTimestamp: UInt64 = 0 + + // MARK: NSCoding + + enum Keys { + static let userID = "userId" // String + static let displayName = "displayname" // String? + static let avatarURL = "avatarUrl" // String? + static let statusMessage = "statusMsg" // String? + static let currentlyActive = "currentlyActive" // Bool + static let lastActiveLocalTimestamp = "lastActiveLocalTS" // UInt64 + static let latestUpdateTimestamp = "latestUpdateTS" // UInt64 + } + + required init?(coder aDecoder: NSCoder) { + guard let userID = aDecoder.decodeObject(forKey: Keys.userID) as? String else { + return nil + } + + self.userID = userID + displayName = aDecoder.decodeObject(forKey: Keys.displayName) as? String + avatarURL = aDecoder.decodeObject(forKey: Keys.avatarURL) as? String + statusMessage = aDecoder.decodeObject(forKey: Keys.statusMessage) as? String + currentlyActive = aDecoder.decodeBool(forKey: Keys.currentlyActive) + // lastActiveLocalTimestamp = UInt64(aDecoder.decodeInt64(forKey: Keys.lastActiveLocalTimestamp)) + // latestUpdateTimestamp = UInt64(aDecoder.decodeInt64(forKey: Keys.latestUpdateTimestamp)) + + super.init() + } + + func encode(with coder: NSCoder) { + fatalError("Not available") + } +} diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift new file mode 100644 index 000000000..48bf1de66 --- /dev/null +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift @@ -0,0 +1,62 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Foundation +import KeychainAccess +import MatrixRustSDK + +protocol ClassicAppManagerProtocol { + func loadAccounts() throws -> [ClassicAppAccount] +} + +enum ClassicAppManagerError: Error { + case invalidAppGroupIdentifier(String) + case missingAccountKeys + case missingCryptoStorePassphrase +} + +final class ClassicAppManager: ClassicAppManagerProtocol { + private enum KeychainKeys: String { + case cryptoSDKStoreKey + case accountIV = "accountIv" + case accountAESKey = "accountAesKey" + } + + private let classicAppGroupIdentifier: String + private let keychain: Keychain + + init(classicAppGroupIdentifier: String, classicAppKeychainServiceIdentifier: String, classicAppKeychainAccessGroupIdentifier: String) { + self.classicAppGroupIdentifier = classicAppGroupIdentifier + keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier) + } + + func loadAccounts() throws -> [ClassicAppAccount] { + guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else { + throw ClassicAppManagerError.invalidAppGroupIdentifier(classicAppGroupIdentifier) + } + + guard let accountIV = try keychain.getData(KeychainKeys.accountIV.rawValue), + let accountAESKey = try keychain.getData(KeychainKeys.accountAESKey.rawValue) else { + throw ClassicAppManagerError.missingAccountKeys + } + + guard let cryptoStorePassphrase = try keychain.getData(KeychainKeys.cryptoSDKStoreKey.rawValue) else { + throw ClassicAppManagerError.missingCryptoStorePassphrase + } + + let accountManager = ClassicAppAccountManager(cacheFolder: url, iv: accountIV, aesKey: accountAESKey) + accountManager.loadAccounts() + let activeAccounts = accountManager.activeAccounts + + MXLog.info("Loaded \(accountManager.accounts.count) accounts") + MXLog.verbose("Loaded accounts: \(accountManager.accounts.compactMap(\.credentials.userID).formatted(.list(type: .and)))") + MXLog.info("Found \(activeAccounts.count) active accounts") + MXLog.verbose("Active accounts: \(activeAccounts.compactMap(\.userID).formatted(.list(type: .and)))") + + return activeAccounts + } +} diff --git a/ElementX/SupportingFiles/ElementX.entitlements b/ElementX/SupportingFiles/ElementX.entitlements index 267cb0270..6b0737e17 100644 --- a/ElementX/SupportingFiles/ElementX.entitlements +++ b/ElementX/SupportingFiles/ElementX.entitlements @@ -23,12 +23,14 @@ com.apple.security.application-groups $(APP_GROUP_IDENTIFIER) + $(CLASSIC_APP_GROUP_IDENTIFIER) com.apple.security.network.client keychain-access-groups $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index 3322b60c5..d0a8ac9a7 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -116,6 +116,12 @@ $(APP_GROUP_IDENTIFIER) baseBundleIdentifier $(BASE_BUNDLE_IDENTIFIER) + classicAppGroupIdentifier + $(CLASSIC_APP_GROUP_IDENTIFIER) + classicAppKeychainAccessGroupIdentifier + $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + classicAppKeychainServiceIdentifier + $(CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER) keychainAccessGroupIdentifier $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) productionAppName diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 5af6718b1..9f9ce7119 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -76,6 +76,9 @@ targets: appGroupIdentifier: $(APP_GROUP_IDENTIFIER) baseBundleIdentifier: $(BASE_BUNDLE_IDENTIFIER) keychainAccessGroupIdentifier: $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + classicAppGroupIdentifier: $(CLASSIC_APP_GROUP_IDENTIFIER) + classicAppKeychainServiceIdentifier: $(CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER) + classicAppKeychainAccessGroupIdentifier: $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) productionAppName: $(PRODUCTION_APP_NAME) ITSAppUsesNonExemptEncryption: false NSUserActivityTypes: [ @@ -126,9 +129,11 @@ targets: com.apple.security.app-sandbox: true com.apple.security.application-groups: - $(APP_GROUP_IDENTIFIER) + - $(CLASSIC_APP_GROUP_IDENTIFIER) com.apple.security.network.client: true keychain-access-groups: - $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + - $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) settings: base: diff --git a/UnitTests/Sources/AuthenticationServiceTests.swift b/UnitTests/Sources/AuthenticationServiceTests.swift index a9c2d475e..b96366c05 100644 --- a/UnitTests/Sources/AuthenticationServiceTests.swift +++ b/UnitTests/Sources/AuthenticationServiceTests.swift @@ -102,6 +102,7 @@ struct AuthenticationServiceTests { service = AuthenticationService(userSessionStore: userSessionStore, encryptionKeyProvider: encryptionKeyProvider, + classicAppManager: nil, clientFactory: clientFactory, appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift index 5147f699a..380f5d3ae 100644 --- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift @@ -151,6 +151,7 @@ final class AuthenticationStartScreenViewModelTests { clientFactory = AuthenticationClientFactoryMock(configuration: configuration) authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), + classicAppManager: nil, clientFactory: clientFactory, appSettings: appSettings, appHooks: AppHooks()) diff --git a/UnitTests/Sources/LoginScreenViewModelTests.swift b/UnitTests/Sources/LoginScreenViewModelTests.swift index 4c6430667..3a70a6161 100644 --- a/UnitTests/Sources/LoginScreenViewModelTests.swift +++ b/UnitTests/Sources/LoginScreenViewModelTests.swift @@ -230,6 +230,7 @@ struct LoginScreenViewModelTests { clientFactory = AuthenticationClientFactoryMock(configuration: .init()) service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), + classicAppManager: nil, clientFactory: clientFactory, appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) diff --git a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift index 390a0c390..1f072f973 100644 --- a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift @@ -359,6 +359,7 @@ final class ServerConfirmationScreenViewModelTests { clientFactory = AuthenticationClientFactoryMock(configuration: configuration) service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), + classicAppManager: nil, clientFactory: clientFactory, appSettings: appSettings, appHooks: AppHooks()) diff --git a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift index a37f601c4..a7a3a8d3d 100644 --- a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift @@ -151,6 +151,7 @@ struct ServerSelectionScreenViewModelTests { clientFactory = AuthenticationClientFactoryMock(configuration: .init()) service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), + classicAppManager: nil, clientFactory: clientFactory, appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) diff --git a/app.yml b/app.yml index 7de1f4953..2c398ab62 100644 --- a/app.yml +++ b/app.yml @@ -5,3 +5,8 @@ settings: BASE_BUNDLE_IDENTIFIER: io.element.elementx ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: "colors/accent-color" DEVELOPMENT_TEAM: 7J4U792NQT + + # Optional configuration to load accounts from Element Classic. + CLASSIC_APP_GROUP_IDENTIFIER: group.im.vector + CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: im.vector.app.encryption-manager-service + CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"