diff --git a/docs/adr/0035-iOS-NSE-issue.md b/docs/adr/0035-iOS-NSE-issue.md new file mode 100644 index 0000000000..621d08b188 --- /dev/null +++ b/docs/adr/0035-iOS-NSE-issue.md @@ -0,0 +1,51 @@ +# 35. Fix iOS NSE Decryption Issue + +Date: 2026-03-06 + +## Status + +Accepted + +## Context + +iOS background notification only shows as "You have 1 encrypted message", whether the message is encrypted or not. + +### Problem 1: Session stopped syncing + +- Before Matrix Dart SDK migration, We was using FlutterHiveCollectionsDatabase as database, and it was using \_updateIOSKeychainSharingRestoreToken to sync session to iOS keychain. +- After Matrix Dart SDK migration, We are using MatrixSdkDatabase as database, and FlutterHiveCollectionsDatabase now only exist to migrate old data, so \_updateIOSKeychainSharingRestoreToken is never called. + +### Problem 2: Twake Chat was never confirmed to decrypt notification successfully + +- From https://github.com/linagora/twake-on-matrix/issues/1049, only unencrypted notification was successfully shown. +- Matrix Rust SDK implementation was 100% copied from Element X iOS repo to NSE. However, Element X iOS also use Matrix Rust SDK in their main app, so the database where keys live is the same. +- We never properly set up Matrix Rust SDK database in NSE, so even though Matrix Rust SDK has the ability to decrypt encrypted messages, it doesn't have the keys to do so. + +## Solution + +### 1. Resync the session + +To ensure the Notification Service Extension (NSE) has the latest session context, we actively synchronize the session to the iOS keychain. + +- Added `KeychainSharingManager.saveSession()` to save `accessToken`, `userId`, `homeserverUrl`, and `deviceId` to the shared secure storage. +- Added a listener in `BackgroundPush._setupKeychainSyncListener` to intercept `client.onSync` events and save the session to the keychain. +- Added `_syncKeychainForClient` in `ClientManager` to save the session whenever a client is initialized. + +### 2. Push the recovery key from Flutter side to iOS side + +In order to allow the Matrix Rust SDK within the NSE to decrypt messages, it must have access to the recovery key. + +- Implemented `BackgroundPush._syncRecoveryKeyToKeychain` to fetch the recovery words via `GetRecoveryWordsInteractor` and push them to the iOS keychain using `KeychainSharingManager.saveRecoveryKey`. +- Added logic to `SettingsController._logoutActions` to delete this recovery key from the keychain upon logout. + +## Consequences + +### Benefits + +- **Successful Decryption**: The NSE now has the necessary session details and keys to instantiate the Matrix Rust SDK and decrypt incoming messages in the background. +- **Improved UX**: Users will see the actual message content rather than a generic "You have 1 encrypted message" notification. + +### Risks and Mitigations + +- **Security**: Storing the recovery key in the shared keychain increases exposure slightly, but it is necessary for background decryption and is limited to the designated keychain access group. +- **State consistency**: Session tokens and recovery keys are correctly cleared upon user logout to prevent unauthorized background process access. diff --git a/ios/NSE/KeychainController.swift b/ios/NSE/KeychainController.swift index f1545b123d..7c2283f4ae 100644 --- a/ios/NSE/KeychainController.swift +++ b/ios/NSE/KeychainController.swift @@ -88,12 +88,24 @@ class KeychainController: KeychainControllerProtocol { } } + // MARK: - Recovery Key + + func recoveryKey(forUsername username: String) -> String? { + let keychainKey = "ssss_recovery_\(username)" + do { + return try keychain.getString(keychainKey) + } catch { + MXLog.error("Failed retrieving SSSS recovery key: \(error)") + return nil + } + } + // MARK: - ClientSessionDelegate func retrieveSessionFromKeychain(userId: String) throws -> Session { MXLog.info("Retrieving an updated Session from the keychain.") guard let session = restorationTokenForUsername(userId)?.session else { - throw ClientError.Generic(msg: "Failed to find RestorationToken in the Keychain.") + throw ClientError.Generic(msg: "Failed to find RestorationToken in the Keychain.", details: nil) } return session } diff --git a/ios/NSE/Logging/MXLog.swift b/ios/NSE/Logging/MXLog.swift index 6b0e236d52..a67d643542 100644 --- a/ios/NSE/Logging/MXLog.swift +++ b/ios/NSE/Logging/MXLog.swift @@ -15,6 +15,7 @@ // import Foundation +import os import MatrixRustSDK /** @@ -59,8 +60,6 @@ enum MXLog { return } - setupTracing(configuration: .init(logLevel: logLevel), otlpConfiguration: otlpConfiguration) - if let target { self.target = target MXLogger.setSubLogName(target) @@ -68,7 +67,7 @@ enum MXLog { self.target = Constants.target } - rootSpan = Span(file: #file, line: #line, level: .info, target: self.target, name: "root") + rootSpan = Span(file: #file, line: UInt32(#line), level: .info, target: self.target, name: "root", bridgeTraceId: nil) rootSpan.enter() @@ -174,7 +173,7 @@ enum MXLog { rootSpan.enter() } - return Span(file: file, line: UInt32(line), level: level, target: target, name: name) + return Span(file: file, line: UInt32(line), level: level, target: target, name: name, bridgeTraceId: nil) } private static func log(_ message: Any, @@ -192,6 +191,8 @@ enum MXLog { rootSpan.enter() } - logEvent(file: (file as NSString).lastPathComponent, line: UInt32(line), level: level, target: target, message: "\(message)") + let messageString = "\(message)" + logEvent(file: (file as NSString).lastPathComponent, line: UInt32(line), level: level, target: target, message: messageString) + os_log("MXLog: %{public}@", messageString) } } diff --git a/ios/NSE/Logging/RustTracing.swift b/ios/NSE/Logging/RustTracing.swift index 7774ffc08f..b73849baea 100644 --- a/ios/NSE/Logging/RustTracing.swift +++ b/ios/NSE/Logging/RustTracing.swift @@ -132,19 +132,3 @@ struct TracingConfiguration { filter = components.joined(separator: ",") } } - -func setupTracing(configuration: TracingConfiguration, otlpConfiguration: OTLPConfiguration?) { - if let otlpConfiguration { - setupOtlpTracing(config: .init(clientName: "ElementX-iOS", - user: otlpConfiguration.username, - password: otlpConfiguration.password, - otlpEndpoint: otlpConfiguration.url, - filter: configuration.filter, - writeToStdoutOrSystem: true, - writeToFiles: nil)) - } else { - setupTracing(config: .init(filter: configuration.filter, - writeToStdoutOrSystem: true, - writeToFiles: nil)) - } -} diff --git a/ios/NSE/Provider/MediaFileHandleProxy.swift b/ios/NSE/Provider/MediaFileHandleProxy.swift index 5926413709..b7b538b1c2 100644 --- a/ios/NSE/Provider/MediaFileHandleProxy.swift +++ b/ios/NSE/Provider/MediaFileHandleProxy.swift @@ -38,7 +38,12 @@ class MediaFileHandleProxy { /// The media file's location on disk. var url: URL { - URL(filePath: handle.path()) + do { + return URL(filePath: try handle.path()) + } catch { + MXLog.error("Failed to get media file path: \(error)") + return URL(filePath: "") + } } } @@ -59,9 +64,17 @@ extension MediaFileHandleProxy: Hashable { /// An unmanaged file handle that can be created direct from a URL. /// /// This type allows for mocking but doesn't provide the automatic clean-up mechanism provided by the SDK. -private struct UnmanagedMediaFileHandle: MediaFileHandleProtocol { +private final class UnmanagedMediaFileHandle: MediaFileHandleProtocol { + func persist(path: String) throws -> Bool { + false + } + let url: URL + init(url: URL) { + self.url = url + } + func path() -> String { url.path() } diff --git a/ios/NSE/Provider/MediaLoader.swift b/ios/NSE/Provider/MediaLoader.swift index 3d98d0739e..36ddb7a67c 100644 --- a/ios/NSE/Provider/MediaLoader.swift +++ b/ios/NSE/Provider/MediaLoader.swift @@ -23,10 +23,15 @@ private final class MediaRequest { var continuations: [CheckedContinuation] = [] } +private enum MediaRequestKey: Hashable { + case content(MediaSourceProxy) + case thumbnail(MediaSourceProxy, width: UInt, height: UInt) +} + actor MediaLoader: MediaLoaderProtocol { private let client: ClientProtocol private let clientQueue: DispatchQueue - private var ongoingRequests = [MediaSourceProxy: MediaRequest]() + private var ongoingRequests = [MediaRequestKey: MediaRequest]() init(client: ClientProtocol, clientQueue: DispatchQueue = .global()) { @@ -35,46 +40,41 @@ actor MediaLoader: MediaLoaderProtocol { } func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data { - try await enqueueLoadMediaRequest(forSource: source) { - try self.client.getMediaContent(mediaSource: source.underlyingSource) + try await enqueueLoadMediaRequest(forKey: .content(source)) { + try await self.client.getMediaContent(mediaSource: source.underlyingSource) } } func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data { - try await enqueueLoadMediaRequest(forSource: source) { - try self.client.getMediaThumbnail(mediaSource: source.underlyingSource, width: UInt64(width), height: UInt64(height)) + try await enqueueLoadMediaRequest(forKey: .thumbnail(source, width: width, height: height)) { + try await self.client.getMediaThumbnail(mediaSource: source.underlyingSource, width: UInt64(width), height: UInt64(height)) } } - func loadMediaFileForSource(_ source: MediaSourceProxy, body: String?) async throws -> MediaFileHandleProxy { - let result = try await Task.dispatch(on: clientQueue) { - try self.client.getMediaFile(mediaSource: source.underlyingSource, body: body, mimeType: source.mimeType ?? "application/octet-stream", tempDir: nil) - } + func loadMediaFileForSource(_ source: MediaSourceProxy, body filename: String?) async throws -> MediaFileHandleProxy { + let result = try await self.client.getMediaFile(mediaSource: source.underlyingSource, filename: filename, mimeType: source.mimeType ?? "application/octet-stream", useCache: true, tempDir: nil) return MediaFileHandleProxy(handle: result) } // MARK: - Private - private func enqueueLoadMediaRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> [UInt8]) async throws -> Data { - if let ongoingRequest = ongoingRequests[source] { + private func enqueueLoadMediaRequest(forKey key: MediaRequestKey, operation: @escaping () async throws -> Data) async throws -> Data { + if let ongoingRequest = ongoingRequests[key] { return try await withCheckedThrowingContinuation { continuation in ongoingRequest.continuations.append(continuation) } } let ongoingRequest = MediaRequest() - ongoingRequests[source] = ongoingRequest + ongoingRequests[key] = ongoingRequest defer { - ongoingRequests[source] = nil + ongoingRequests[key] = nil } do { - let result = try await Task.dispatch(on: clientQueue) { - let bytes = try operation() - return Data(bytes: bytes, count: bytes.count) - } + let result = try await operation() ongoingRequest.continuations.forEach { $0.resume(returning: result) } diff --git a/ios/NSE/Provider/MediaSourceProxy.swift b/ios/NSE/Provider/MediaSourceProxy.swift index 17cc69399c..bb3ae17886 100644 --- a/ios/NSE/Provider/MediaSourceProxy.swift +++ b/ios/NSE/Provider/MediaSourceProxy.swift @@ -32,8 +32,9 @@ struct MediaSourceProxy: Hashable { self.mimeType = mimeType } - init(url: URL, mimeType: String?) { - underlyingSource = mediaSourceFromUrl(url: url.absoluteString) + init?(url: URL, mimeType: String?) { + guard let source = try? MediaSource.fromUrl(url: url.absoluteString) else { return nil } + underlyingSource = source self.url = URL(string: underlyingSource.url()) self.mimeType = mimeType } diff --git a/ios/NSE/Proxy/NotificationItemProxyProtocol.swift b/ios/NSE/Proxy/NotificationItemProxyProtocol.swift index 9f1ca8d419..7f3291c3fc 100644 --- a/ios/NSE/Proxy/NotificationItemProxyProtocol.swift +++ b/ios/NSE/Proxy/NotificationItemProxyProtocol.swift @@ -60,12 +60,12 @@ extension NotificationItemProxyProtocol { case .invite, .none: return false case .timeline(let event): - switch try? event.eventType() { + switch try? event.content() { case .state, .none: return false - case let .messageLike(content): + case .messageLike(content: let content): switch content { - case let .roomMessage(messageType, _): + case .roomMessage(messageType: let messageType, inReplyToEventId: _): switch messageType { case .image, .video, .audio: return true diff --git a/ios/NSE/RestorationToken.swift b/ios/NSE/RestorationToken.swift index 353abdc4d6..d931195a12 100644 --- a/ios/NSE/RestorationToken.swift +++ b/ios/NSE/RestorationToken.swift @@ -43,7 +43,7 @@ extension MatrixRustSDK.Session: Codable { deviceId: container.decode(String.self, forKey: .deviceId), homeserverUrl: container.decode(String.self, forKey: .homeserverUrl), oidcData: container.decodeIfPresent(String.self, forKey: .oidcData), - slidingSyncProxy: container.decodeIfPresent(String.self, forKey: .slidingSyncProxy)) + slidingSyncVersion: .native) } public func encode(to encoder: Encoder) throws { @@ -54,7 +54,6 @@ extension MatrixRustSDK.Session: Codable { try container.encode(deviceId, forKey: .deviceId) try container.encode(homeserverUrl, forKey: .homeserverUrl) try container.encode(oidcData, forKey: .oidcData) - try container.encode(slidingSyncProxy, forKey: .slidingSyncProxy) } enum CodingKeys: String, CodingKey { diff --git a/ios/NSE/RoomMessageEventStringBuilder.swift b/ios/NSE/RoomMessageEventStringBuilder.swift index 945083b9c3..a938762803 100644 --- a/ios/NSE/RoomMessageEventStringBuilder.swift +++ b/ios/NSE/RoomMessageEventStringBuilder.swift @@ -53,6 +53,10 @@ struct RoomMessageEventStringBuilder { } else { message = content.body } + case .other(msgtype: let msgtype, body: let body): + message = body + case .gallery(content: let content): + message = content.body } if prefixWithSenderName { diff --git a/ios/NSE/Sources/NotificationContentBuilder.swift b/ios/NSE/Sources/NotificationContentBuilder.swift index 85648b58e4..f66a4c6df4 100644 --- a/ios/NSE/Sources/NotificationContentBuilder.swift +++ b/ios/NSE/Sources/NotificationContentBuilder.swift @@ -33,10 +33,10 @@ struct NotificationContentBuilder { case .invite: return try await processInvited(notificationItem: notificationItem, mediaProvider: mediaProvider) case .timeline(let event): - switch try? event.eventType() { - case let .messageLike(content): + switch try? event.content() { + case .messageLike(content: let content): switch content { - case .roomMessage(let messageType, _): + case .roomMessage(messageType: let messageType, inReplyToEventId: _): return try await processRoomMessage(notificationItem: notificationItem, messageType: messageType, mediaProvider: mediaProvider) default: return processEmpty(notificationItem: notificationItem) diff --git a/ios/NSE/Sources/NotificationServiceExtension.swift b/ios/NSE/Sources/NotificationServiceExtension.swift index 979b95b326..1cd2b56b47 100644 --- a/ios/NSE/Sources/NotificationServiceExtension.swift +++ b/ios/NSE/Sources/NotificationServiceExtension.swift @@ -74,7 +74,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension { MXLog.info("\(tag) run with roomId: \(roomId), eventId: \(eventId)") do { - let userSession = try NSEUserSession(credentials: credentials, clientSessionDelegate: keychainController) + let recoveryKey = keychainController.recoveryKey(forUsername: credentials.userID) + let userSession = try await NSEUserSession(credentials: credentials, roomID: roomId, clientSessionDelegate: keychainController, recoveryKey: recoveryKey) self.userSession = userSession guard let itemProxy = await userSession.notificationItemProxy(roomID: roomId, eventID: eventId) else { @@ -128,7 +129,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension { private func discard() { MXLog.info("\(tag) discard") - handler?(UNMutableNotificationContent()) + handler?(modifiedContent ?? UNMutableNotificationContent()) cleanUp() } diff --git a/ios/NSE/Sources/Other/NSEUserSession.swift b/ios/NSE/Sources/Other/NSEUserSession.swift index 77161df90e..8d51a7fb2c 100644 --- a/ios/NSE/Sources/Other/NSEUserSession.swift +++ b/ios/NSE/Sources/Other/NSEUserSession.swift @@ -21,45 +21,65 @@ final class NSEUserSession { private let baseClient: Client private let notificationClient: NotificationClient private let userID: String + private let delegateHandle: TaskHandle? private(set) lazy var mediaProvider: MediaProviderProtocol = MediaProvider(mediaLoader: MediaLoader(client: baseClient), imageCache: .onlyOnDisk, backgroundTaskService: nil) - init(credentials: KeychainCredentials, clientSessionDelegate: ClientSessionDelegate) throws { + init(credentials: KeychainCredentials, + roomID: String, + clientSessionDelegate: ClientSessionDelegate, + recoveryKey: String?) async throws { userID = credentials.userID - baseClient = try ClientBuilder() - .basePath(path: URL.sessionsBaseDirectory.path) + baseClient = try await ClientBuilder() + .sessionPaths(dataPath: URL.sessionsBaseDirectory(userId: userID, deviceId: credentials.restorationToken.session.deviceId).path, + cachePath: URL.cacheBaseDirectory(userId: userID, deviceId: credentials.restorationToken.session.deviceId).path) .username(username: credentials.userID) .userAgent(userAgent: UserAgentBuilder.makeASCIIUserAgent()) - .enableCrossProcessRefreshLock(processId: InfoPlistReader.main.bundleIdentifier, - sessionDelegate: clientSessionDelegate) + .backupDownloadStrategy(backupDownloadStrategy: .afterDecryptionFailure) + .crossProcessStoreLocksHolderName(holderName: InfoPlistReader.main.bundleIdentifier) + .setSessionDelegate(sessionDelegate: clientSessionDelegate) .build() - baseClient.setDelegate(delegate: ClientDelegateWrapper()) - try baseClient.restoreSession(session: credentials.restorationToken.session) + delegateHandle = try baseClient.setDelegate(delegate: ClientDelegateWrapper()) + try await baseClient.restoreSessionWith(session: credentials.restorationToken.session, + roomLoadSettings: .one(roomId: roomID)) - notificationClient = try baseClient + if let recoveryKey { + do { + MXLog.info("NSE: Registering backup recovery key...") + try await baseClient.encryption().recover(recoveryKey: recoveryKey) + MXLog.info("NSE: Backup recovery key registered successfully") + } catch { + MXLog.warning("NSE: Failed to register backup recovery key (will attempt decryption without it): \(error)") + } + } else { + MXLog.info("NSE: No backup recovery key found in keychain, skipping recovery") + } + + notificationClient = try await baseClient .notificationClient(processSetup: .multipleProcesses) - .filterByPushRules() - .finish() } func notificationItemProxy(roomID: String, eventID: String) async -> NotificationItemProxyProtocol? { - await Task.dispatch(on: .global()) { - do { - let notification = try self.notificationClient.getNotification(roomId: roomID, eventId: eventID) - - guard let notification else { - return nil - } + do { + let status = try await notificationClient.getNotification(roomId: roomID, eventId: eventID) + switch status { + case .event(let notification): return NotificationItemProxy(notificationItem: notification, eventID: eventID, - receiverID: self.userID, + receiverID: userID, roomID: roomID) - } catch { - MXLog.error("NSE: Could not get notification's content creating an empty notification instead, error: \(error)") - return EmptyNotificationItemProxy(eventID: eventID, roomID: roomID, receiverID: self.userID) + case .eventFilteredOut: + MXLog.info("NSE: Notification event filtered out - roomID: \(roomID) eventID: \(eventID)") + return nil + case .eventNotFound: + MXLog.info("NSE: Notification event not found - roomID: \(roomID) eventID: \(eventID)") + return nil } + } catch { + MXLog.error("NSE: Could not get notification's content, error: \(error)") + return nil } } } @@ -70,8 +90,4 @@ private class ClientDelegateWrapper: ClientDelegate { func didReceiveAuthError(isSoftLogout: Bool) { MXLog.error("Received authentication error, the NSE can't handle this.") } - - func didRefreshTokens() { - MXLog.info("Delegating session updates to the ClientSessionDelegate.") - } } diff --git a/ios/NSE/URL.swift b/ios/NSE/URL.swift index bd8fa98759..e582a6e588 100644 --- a/ios/NSE/URL.swift +++ b/ios/NSE/URL.swift @@ -78,4 +78,18 @@ extension URL: ExpressibleByStringLiteral { return url } + + static func sessionsBaseDirectory(userId: String, deviceId: String) -> URL { + let dir = sessionsBaseDirectory; + let userIdAppendedDir = dir.appendingPathComponent(userId, isDirectory: true).appendingPathComponent(deviceId, isDirectory: true) + try? FileManager.default.createDirectoryIfNeeded(at: userIdAppendedDir) + return userIdAppendedDir + } + + static func cacheBaseDirectory(userId: String, deviceId: String) -> URL { + let dir = cacheBaseDirectory; + let userIdAppendedDir = dir.appendingPathComponent(userId, isDirectory: true).appendingPathComponent(deviceId, isDirectory: true) + try? FileManager.default.createDirectoryIfNeeded(at: userIdAppendedDir) + return userIdAppendedDir + } } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 41fa419c7f..39915b97d5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -1982,7 +1982,7 @@ repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 1.1.20; + version = 26.01.04; }; }; 06EA9CAC2B21D2CA00351529 /* XCRemoteSwiftPackageReference "LRUCache" */ = { diff --git a/lib/domain/keychain_sharing/keychain_sharing_manager.dart b/lib/domain/keychain_sharing/keychain_sharing_manager.dart index 5ae4e7e232..a9f4ae60c8 100644 --- a/lib/domain/keychain_sharing/keychain_sharing_manager.dart +++ b/lib/domain/keychain_sharing/keychain_sharing_manager.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_restore_token.dart'; +import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_session.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:matrix/matrix.dart'; @@ -10,11 +12,68 @@ class KeychainSharingManager { iOptions: IOSOptions( groupId: AppConfig.iOSKeychainSharingId, accountName: AppConfig.iOSKeychainSharingAccount, - synchronizable: true, + synchronizable: false, accessibility: KeychainAccessibility.first_unlock_this_device, ), ); + static const String _ssssRecoveryKeyPrefix = 'ssss_recovery_'; + + static Future saveRecoveryKey({ + required String? userId, + required String recoveryKey, + }) async { + if (!PlatformInfos.isIOS || userId == null) return; + try { + await _secureStorage.write( + key: '$_ssssRecoveryKeyPrefix$userId', + value: recoveryKey, + ); + Logs().d('[KeychainSharing] Saved SSSS recovery key for $userId'); + } catch (e, s) { + Logs().wtf('[KeychainSharing] Unable to save SSSS recovery key', e, s); + } + } + + static Future deleteRecoveryKey({required String? userId}) async { + if (!PlatformInfos.isIOS || userId == null) return; + try { + await _secureStorage.delete(key: '$_ssssRecoveryKeyPrefix$userId'); + Logs().d('[KeychainSharing] Deleted SSSS recovery key for $userId'); + } catch (e, s) { + Logs().wtf('[KeychainSharing] Unable to delete SSSS recovery key', e, s); + } + } + + static Future saveSession({ + required String accessToken, + required String userId, + required String homeserverUrl, + String deviceId = '', + }) async { + try { + final oldToken = await read(userId: userId); + if (oldToken?.session.accessToken == accessToken && + oldToken?.session.userId == userId && + oldToken?.session.homeserverUrl == homeserverUrl && + oldToken?.session.deviceId == deviceId) { + return; + } + final token = KeychainSharingRestoreToken( + session: KeychainSharingSession( + accessToken: accessToken, + userId: userId, + deviceId: deviceId, + homeserverUrl: homeserverUrl, + ), + ); + await save(token); + Logs().d('[KeychainSharing] Saved restore token for $userId'); + } catch (e, s) { + Logs().w('[KeychainSharing] Unable to save restore token', e, s); + } + } + static Future save(KeychainSharingRestoreToken token) => _secureStorage.write( key: token.session.userId, value: jsonEncode(token.toJson()), diff --git a/lib/pages/bootstrap/bootstrap_dialog.dart b/lib/pages/bootstrap/bootstrap_dialog.dart index b2cc389bf7..9e62525e3d 100644 --- a/lib/pages/bootstrap/bootstrap_dialog.dart +++ b/lib/pages/bootstrap/bootstrap_dialog.dart @@ -1,5 +1,6 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_manager.dart'; import 'package:fluffychat/pages/bootstrap/tom_bootstrap_dialog.dart'; import 'package:fluffychat/pages/key_verification/key_verification_dialog.dart'; import 'package:fluffychat/utils/dialog/twake_dialog.dart'; @@ -299,6 +300,10 @@ class BootstrapDialogState extends State { .selfSign(keyOrPassphrase: key); Logs().d('Successful elfsigned'); await bootstrap.openExistingSsss(); + await KeychainSharingManager.saveRecoveryKey( + userId: bootstrap.client.userID, + recoveryKey: key, + ); } catch (e, s) { Logs().w('Unable to unlock SSSS', e, s); setState( diff --git a/lib/pages/bootstrap/tom_bootstrap_dialog.dart b/lib/pages/bootstrap/tom_bootstrap_dialog.dart index b1368538cd..b7eb5e1f04 100644 --- a/lib/pages/bootstrap/tom_bootstrap_dialog.dart +++ b/lib/pages/bootstrap/tom_bootstrap_dialog.dart @@ -1,5 +1,6 @@ import 'package:fluffychat/di/global/dio_cache_interceptor_for_client.dart'; import 'package:fluffychat/di/global/get_it_initializer.dart'; +import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_manager.dart'; import 'package:fluffychat/domain/model/recovery_words/recovery_words.dart'; import 'package:fluffychat/domain/usecase/recovery/delete_recovery_words_interactor.dart'; import 'package:fluffychat/domain/usecase/recovery/get_recovery_words_interactor.dart'; @@ -214,10 +215,14 @@ class TomBootstrapDialogState extends State 'TomBootstrapDialogState::build(): start backup process with key ${bootstrap?.newSsssKey!.recoveryKey}', ); final key = bootstrap?.newSsssKey!.recoveryKey; - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { Logs().i( 'TomBootstrapDialogState::build(): check if key is already in TOM = ${_existedRecoveryWordsInTom(key)} - ${_recoveryWords?.words}', ); + await KeychainSharingManager.saveRecoveryKey( + userId: widget.client.userID, + recoveryKey: key!, + ); if (_existedRecoveryWordsInTom(key)) { _uploadRecoveryKeyState = UploadRecoveryKeyState.uploaded; return; @@ -364,7 +369,9 @@ class TomBootstrapDialogState extends State case BootstrapState.done: WidgetsBinding.instance.addPostFrameCallback((_) { Matrix.of(context).showToMBootstrap.value = false; - Navigator.of(context, rootNavigator: false).pop(true); + if (Navigator.canPop(context)) { + Navigator.of(context, rootNavigator: false).pop(true); + } }); break; } @@ -446,6 +453,10 @@ class TomBootstrapDialogState extends State ); Logs().i('TomBootstrapDialogState::_unlockBackUp() open existing SSSS'); await bootstrap?.openExistingSsss(); + await KeychainSharingManager.saveRecoveryKey( + userId: widget.client.userID, + recoveryKey: recoveryWords.words, + ); } catch (e, s) { Logs().w( 'TomBootstrapDialogState::_unlockBackUp() Unable to unlock SSSS', diff --git a/lib/pages/settings_dashboard/settings/settings.dart b/lib/pages/settings_dashboard/settings/settings.dart index d6066aeb39..10329e9364 100644 --- a/lib/pages/settings_dashboard/settings/settings.dart +++ b/lib/pages/settings_dashboard/settings/settings.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/presentation/mixins/connect_page_mixin.dart'; import 'package:fluffychat/presentation/enum/settings/settings_enum.dart'; import 'package:fluffychat/presentation/extensions/client_extension.dart'; import 'package:fluffychat/utils/dialog/twake_dialog.dart'; +import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_manager.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -119,10 +120,12 @@ class SettingsController extends State with ConnectPageMixin { if (matrix.backgroundPush != null) { await matrix.backgroundPush!.removeCurrentPusher(); } + final userId = matrix.client.userID; await Future.wait([ matrix.client.logout(), _deleteTomConfigurations(matrix.client), _deleteFederationConfigurations(matrix.client), + KeychainSharingManager.deleteRecoveryKey(userId: userId), ]); } catch (e) { Logs().e('SettingsController()::_logoutActionsOnMobile - error: $e'); @@ -138,10 +141,12 @@ class SettingsController extends State with ConnectPageMixin { if (matrix.backgroundPush != null) { await matrix.backgroundPush!.removeCurrentPusher(); } + final userId = matrix.client.userID; await Future.wait([ matrix.client.logout(), _deleteTomConfigurations(matrix.client), _deleteFederationConfigurations(matrix.client), + KeychainSharingManager.deleteRecoveryKey(userId: userId), ]); } catch (e) { Logs().e('SettingsController()::_logoutActions - error: $e'); diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart index 70d2a3433b..6b1fda1236 100644 --- a/lib/utils/background_push.dart +++ b/lib/utils/background_push.dart @@ -21,8 +21,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:fcm_shared_isolate/fcm_shared_isolate.dart'; +import 'package:fluffychat/di/global/get_it_initializer.dart'; +import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_manager.dart'; import 'package:fluffychat/domain/model/extensions/push/push_notification_extension.dart'; +import 'package:fluffychat/domain/usecase/recovery/get_recovery_words_interactor.dart'; import 'package:fluffychat/presentation/extensions/client_extension.dart'; import 'package:fluffychat/presentation/extensions/go_router_extensions.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; @@ -105,10 +109,198 @@ class BackgroundPush { if (call.method == 'willPresent') { onReceiveNotification(call.arguments); } else if (call.method == 'didReceive') { - iOSUserSelectedNoti(call.arguments); + await iOSUserSelectedNoti(call.arguments); } }); getIosInitialNoti(); + _setupKeychainSyncForClient(client); + } + } + + final Map _keychainSyncSubs = {}; + + bool _isActiveClient(Client targetClient) => + _matrixState?.client.userID == targetClient.userID; + + Future _syncRecoveryKeyForUserId(String userId) async { + final result = await getIt.get().execute(); + await result.fold( + (failure) async => Logs().d( + '[KeychainSharing] No recovery words found to sync for $userId: $failure', + ), + (success) => KeychainSharingManager.saveRecoveryKey( + userId: userId, + recoveryKey: success.words.words, + ), + ); + } + + void _setupKeychainSyncForClient(Client targetClient) { + String? lastAccessToken; + bool didSyncRecoveryKey = false; + + final sub = targetClient.onSync.stream.listen((_) async { + final accessToken = targetClient.accessToken; + if (accessToken == null || !targetClient.isLogged()) return; + + if (accessToken != lastAccessToken) { + lastAccessToken = accessToken; + final userId = targetClient.userID; + final homeserver = targetClient.homeserver?.toString(); + if (userId != null && homeserver != null) { + await KeychainSharingManager.saveSession( + accessToken: accessToken, + userId: userId, + deviceId: targetClient.deviceID ?? '', + homeserverUrl: homeserver, + ); + } + } + + if (!didSyncRecoveryKey && _isActiveClient(targetClient)) { + final userId = targetClient.userID; + if (userId != null) await _syncRecoveryKeyForUserId(userId); + didSyncRecoveryKey = true; + } + }); + + final userId = targetClient.userID; + if (userId != null) { + _keychainSyncSubs[userId]?.cancel(); + _keychainSyncSubs[userId] = sub; + } + } + + void cancelKeychainSyncForClient(Client targetClient) { + final userId = targetClient.userID; + if (userId == null) return; + _keychainSyncSubs[userId]?.cancel(); + _keychainSyncSubs.remove(userId); + } + + Future syncRecoveryKeyForClient(Client targetClient) async { + if (!PlatformInfos.isIOS) return; + final userId = targetClient.userID; + if (userId == null) return; + await _syncRecoveryKeyForUserId(userId); + } + + Future _setupPusherForClient({ + required Client targetClient, + required String gatewayUrl, + required String token, + }) async { + final clientName = PlatformInfos.clientName; + final appId = AppConfig.pushNotificationsAppId; + + final pushers = + await targetClient.getPushers().catchError((e) { + Logs().w( + '[Push] Unable to request pushers for ${targetClient.userID}', + e, + ); + return []; + }) ?? + []; + + final currentPushers = pushers.where((p) => p.pushkey == token); + if (currentPushers.length == 1 && + currentPushers.first.kind == 'http' && + currentPushers.first.appId == appId && + currentPushers.first.appDisplayName == clientName && + currentPushers.first.deviceDisplayName == targetClient.deviceName && + currentPushers.first.lang == 'en' && + currentPushers.first.data.url.toString() == gatewayUrl && + currentPushers.first.data.format == + AppConfig.pushNotificationsPusherFormat) { + Logs().i('[Push] Pusher already set for ${targetClient.userID}'); + return; + } + + try { + await targetClient.postPusher( + Pusher( + pushkey: token, + appId: appId, + appDisplayName: clientName, + deviceDisplayName: targetClient.deviceName!, + lang: 'en', + data: PusherData( + url: Uri.parse(gatewayUrl), + format: AppConfig.pushNotificationsPusherFormat, + additionalProperties: { + "default_payload": { + "aps": { + "mutable-content": 1, + "content-available": 1, + "badge": 1, + "sound": "default", + "alert": {"loc-key": "newMessageInTwake", "loc-args": []}, + }, + "pusher_notification_client_identifier": + targetClient.pusherNotificationClientIdentifier, + }, + }, + ), + kind: 'http', + ), + append: false, + ); + Logs().i( + '[Push] Pusher registered for additional account ${targetClient.userID}', + ); + } catch (e, s) { + Logs().e('[Push] Unable to set pusher for ${targetClient.userID}', e, s); + } + } + + Future setupPushForAdditionalClient(Client additionalClient) async { + if (!PlatformInfos.isIOS) return; + + _setupKeychainSyncForClient(additionalClient); + + final token = _pushToken; + if (token == null) { + Logs().w( + '[Push] setupPushForAdditionalClient: no APN token cached yet for ${additionalClient.userID}', + ); + return; + } + await _setupPusherForClient( + targetClient: additionalClient, + gatewayUrl: AppConfig.pushNotificationsGatewayUrl, + token: token, + ); + } + + Future removePusherForClient(Client targetClient) async { + if (!PlatformInfos.isIOS) return; + final token = _pushToken; + if (token == null) return; + + try { + final pushers = + await targetClient.getPushers().catchError((e) { + Logs().w( + '[Push] Unable to get pushers for ${targetClient.userID}', + e, + ); + return []; + }) ?? + []; + + for (final pusher in pushers) { + if (pusher.pushkey == token) { + await targetClient.deletePusher(pusher); + Logs().i('[Push] Removed pusher for ${targetClient.userID}'); + } + } + } catch (e, s) { + Logs().e( + '[Push] Failed to remove pusher for ${targetClient.userID}', + e, + s, + ); } } @@ -332,6 +524,14 @@ class BackgroundPush { gatewayUrl: AppConfig.pushNotificationsGatewayUrl, token: _pushToken, ); + + if (PlatformInfos.isIOS && _matrixState != null) { + for (final additionalClient in _matrixState!.widget.clients) { + if (additionalClient == client) continue; + if (!additionalClient.isLogged()) continue; + await setupPushForAdditionalClient(additionalClient); + } + } } Future getIosInitialNoti() async { @@ -339,18 +539,47 @@ class BackgroundPush { final noti = await apnChannel.invokeMethod('getInitialNoti'); Logs().v('[Push] Got initial notification: $noti'); if (noti != null) { - iOSUserSelectedNoti(noti); + await iOSUserSelectedNoti(noti); } } catch (e, s) { Logs().e('[Push] Failed to get initial notification', e, s); } } - void iOSUserSelectedNoti(dynamic noti) { + Future iOSUserSelectedNoti(dynamic noti) async { // roomId is payload if noti is local - final roomId = noti['room_id'] ?? noti['payload']; + String? roomId = noti['room_id']; final eventId = noti['event_id']; - goToRoom(roomId, eventId: eventId); + String? receiverId = noti['receiver_id'] as String?; + final payload = noti['payload'] != null + ? jsonDecode(noti['payload']) + : null; + if (payload is Map) { + payload['room_id'] is String ? roomId = payload['room_id'] : null; + payload['receiver_id'] is String + ? receiverId = payload['receiver_id'] + : null; + } + await _switchAccount(receiverId); + await goToRoom(roomId, eventId: eventId); + } + + Future _switchAccount(String? receiverId) async { + Client? targetClient; + if (receiverId != null && _matrixState != null) { + targetClient = _matrixState!.widget.clients.firstWhereOrNull( + (c) => c.userID == receiverId, + ); + if (targetClient != null && + targetClient.userID != _matrixState!.client.userID) { + Logs().d( + '[Push] Switching to account ${targetClient.userID} for notification', + ); + await _matrixState!.setActiveClient(targetClient); + await _matrixState!.cancelListenSynchronizeContacts(); + await _matrixState!.reSyncContacts(); + } + } } void onReceiveNotification(dynamic message) { @@ -370,11 +599,19 @@ class BackgroundPush { Future onSelectNotification( NotificationResponse? response, { String? eventId, - }) { + }) async { + final payload = jsonDecode(response?.payload ?? '{}'); + final roomId = payload['room_id']; + final receiverId = payload['receiver_id']; Logs().d( - 'BackgroundPush::onSelectNotification() roomId - ${response?.payload} ||eventId - $eventId', + 'BackgroundPush::onSelectNotification() roomId - $roomId ||eventId - $eventId', ); - return goToRoom(response?.payload, eventId: eventId); + if (receiverId is String?) { + await _switchAccount(receiverId); + } + if (roomId is String?) { + await goToRoom(roomId, eventId: eventId); + } } Future goToRoom(String? roomId, {String? eventId}) async { diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 73d3e5b252..3f2758b946 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:fluffychat/domain/keychain_sharing/keychain_sharing_manager.dart'; import 'package:fluffychat/utils/custom_http_client.dart'; import 'package:fluffychat/utils/custom_image_resizer.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/flutter_hive_collections_database.dart'; @@ -40,6 +41,7 @@ abstract class ClientManager { waitForFirstSync: false, waitUntilLoadCompletedLoaded: false, ) + .then((_) => _syncKeychainForClient(client)) .catchError( (e, s) => Logs().e('Unable to initialize client', e, s), ), @@ -87,6 +89,20 @@ abstract class ClientManager { await Store().setItem(clientNamespace, jsonEncode(clientNamesList)); } + static Future _syncKeychainForClient(Client client) async { + if (!PlatformInfos.isIOS || !client.isLogged()) return; + final accessToken = client.accessToken; + final userId = client.userID; + final homeserver = client.homeserver?.toString(); + if (accessToken == null || userId == null || homeserver == null) return; + await KeychainSharingManager.saveSession( + accessToken: accessToken, + userId: userId, + deviceId: client.deviceID ?? '', + homeserverUrl: homeserver, + ); + } + static NativeImplementations get nativeImplementations => kIsWeb ? const NativeImplementationsDummy() : NativeImplementationsIsolate(compute, vodozemacInit: () => vod.init()); diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index 8d329a4869..c5f5fbc8e5 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -220,12 +220,14 @@ Future _tryPushHelper( iOS: iOSPlatformChannelSpecifics, ); + final receiverId = event.room.client.userID; + await flutterLocalNotificationsPlugin.show( id, event.room.getLocalizedDisplayname(MatrixLocals(l10n)), body, platformChannelSpecifics, - payload: event.roomId, + payload: jsonEncode({'room_id': event.roomId, 'receiver_id': receiverId}), ); Logs().v('Push helper has been completed!'); } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 8cd5dd65c6..9a6930456a 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -564,6 +564,12 @@ class MatrixState extends State Client currentClient, ) async { waitForFirstSync = false; + + if (PlatformInfos.isIOS) { + backgroundPush?.cancelKeychainSyncForClient(currentClient); + await backgroundPush?.removePusherForClient(currentClient); + } + await _cancelSubs(currentClient.clientName); widget.clients.remove(currentClient); await ClientManager.removeClientNameFromStore(currentClient.clientName); @@ -623,7 +629,17 @@ class MatrixState extends State waitForFirstSync = false; await setUpToMServicesInLogin(activeClient); await setUpFederationServicesInLogin(activeClient); + + if (PlatformInfos.isIOS) { + await backgroundPush?.syncRecoveryKeyForClient(activeClient); + } + final result = await setActiveClient(activeClient); + + if (PlatformInfos.isIOS) { + await backgroundPush?.setupPushForAdditionalClient(activeClient); + } + await matrixState.cancelListenSynchronizeContacts(); matrixState.reSyncContacts(); if (result.isSuccess) {