// // Copyright 2025 Element Creations Ltd. // Copyright 2023-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 Combine import Foundation import MatrixRustSDK import Synchronization class SecureBackupController: SecureBackupControllerProtocol { private let encryption: Encryption private let recoveryStateSubject = CurrentValueSubject(.unknown) private let keyBackupStateSubject = CurrentValueSubject(.unknown) // periphery:ignore - retaining purpose private var backupStateListenerTaskHandle: TaskHandle? // periphery:ignore - required for instance retention in the rust codebase private var recoveryStateListenerTaskHandle: TaskHandle? /// Used to dedupe remote backup state requests @CancellableTask private var remoteBackupStateTask: Task? var recoveryState: CurrentValuePublisher { recoveryStateSubject.asCurrentValuePublisher() } var keyBackupState: CurrentValuePublisher { keyBackupStateSubject.asCurrentValuePublisher() } init(encryption: Encryption) { self.encryption = encryption backupStateListenerTaskHandle = encryption.backupStateListener(listener: SDKListener.onMainActor { [weak self] state in guard let self else { return } switch state { case .unknown: keyBackupStateSubject.send(.unknown) case .creating: keyBackupStateSubject.send(.enabling) case .enabling: keyBackupStateSubject.send(.enabling) case .resuming: keyBackupStateSubject.send(.enabled) case .enabled: keyBackupStateSubject.send(.enabled) case .downloading: keyBackupStateSubject.send(.enabled) case .disabling: keyBackupStateSubject.send(.disabling) } MXLog.info("Key backup state changed to: \(state), setting local state to \(keyBackupStateSubject.value)") if case .unknown = state { updateBackupStateFromRemote() } }) recoveryStateListenerTaskHandle = encryption.recoveryStateListener(listener: SDKListener.onMainActor { [weak self] state in guard let self else { return } switch state { case .unknown: recoveryStateSubject.send(.unknown) case .enabled: recoveryStateSubject.send(.enabled) case .disabled: recoveryStateSubject.send(.disabled) case .incomplete: recoveryStateSubject.send(.incomplete) } MXLog.info("Recovery state changed to: \(state), setting local state to \(recoveryStateSubject.value)") }) } func enable() async -> Result { MXLog.info("Enabling secure backup") do { try await encryption.enableBackups() } catch { MXLog.error("Failed enabling secure backup with error: \(error)") return .failure(.failedEnablingBackup) } return .success(()) } func disable() async -> Result { MXLog.info("Disabling secure backup") do { try await encryption.disableRecovery() } catch { MXLog.error("Failed disabling secure backup with error: \(error)") return .failure(.failedDisablingBackup) } return .success(()) } func generateRecoveryKey(withPassphrase passphrase: String?) async -> Result { do { if recoveryState.value == .disabled { MXLog.info("Enabling recovery") } else { MXLog.info("Resetting recovery key") } let keyUploadErrored = Mutex(false) // Note: `enableRecovery` also handles the reset case (equivalent to `resetRecoveryKey`). // Despite the name, it is the correct call for both initial setup and key rotation. let recoveryKey = try await encryption.enableRecovery(waitForBackupsToUpload: false, passphrase: passphrase, progressListener: SDKListener.onMainActor { [weak self] state in guard let self else { return } switch state { case .starting, .creatingBackup, .creatingRecoveryKey, .backingUp: recoveryStateSubject.send(.settingUp) case .done: recoveryStateSubject.send(.enabled) case .roomKeyUploadError: MXLog.error("Failed enabling recovery: room key upload error") keyUploadErrored.withLock { $0 = true } } }) return keyUploadErrored.withLock { $0 } ? .failure(.failedGeneratingRecoveryKey) : .success(recoveryKey) } catch { MXLog.error("Failed generating recovery key with error: \(error)") return .failure(.failedGeneratingRecoveryKey) } } func confirmRecoveryKey(_ key: String) async -> Result { do { MXLog.info("Confirming recovery key") try await encryption.recoverAndFixBackup(recoveryKey: key) return .success(()) } catch { MXLog.info("Failed confirming recovery key with error: \(error)") return .failure(.failedConfirmingRecoveryKey) } } func waitForKeyBackupUpload(uploadStateSubject: CurrentValueSubject) async -> Result { do { MXLog.info("Waiting for backup upload steady state") try await encryption.waitForBackupUploadSteadyState(progressListener: SDKListener.onMainActor { state in let uploadState: SecureBackupSteadyState = switch state { case .waiting: .waiting case .uploading(let backedUpCount, let totalCount): .uploading(uploadedKeyCount: Int(backedUpCount), totalKeyCount: Int(totalCount)) case .error: .error case .done: .done } uploadStateSubject.send(uploadState) }) return .success(()) } catch let error as SteadyStateError { MXLog.error("Failed waiting for backup upload steady state with error: \(error)") switch error { case .BackupDisabled: MXLog.error("Key backup disabled, continuing logout.") return .success(()) case .Connection, .Lagged: MXLog.error("Key backup upload failure: \(error)") return .failure(.failedUploadingForBackup) } } catch { MXLog.error("Unknown key backup upload failure") return .failure(.failedUploadingForBackup) } } // MARK: - Private private func updateBackupStateFromRemote(retry: Bool = true) { remoteBackupStateTask = Task { [weak self] in guard let self else { return } do { MXLog.info("Checking if backup exists on the server") let backupExists = try await self.encryption.backupExistsOnServer() if Task.isCancelled { return } if !backupExists { keyBackupStateSubject.send(.unknown) } } catch { MXLog.error("Failed retrieving remote backup state with error: \(error)") if retry { updateBackupStateFromRemote(retry: false) } } } } }