// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2018-2020 Threema GmbH // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License, version 3, // as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import Foundation import CocoaLumberjackSwift @objc class SafeManager: NSObject { private var safeConfigManager: SafeConfigManagerProtocol private var safeStore: SafeStore private var safeApiService: SafeApiService private var logger: ValidationLogger //trigger safe backup states private static var backupObserver: NSObjectProtocol? private static var backupDelay: Timer? private static let backupProcessLock: DispatchQueue = DispatchQueue(label: "backupProcessLock") private static var backupProcessStart: Bool = false private static var backupIsRunning: Bool = false private var backupForce: Bool = false private var backupCompletionHandler: (() -> Void)? = nil private var checksum: [UInt8]? enum SafeError: Error { case activateFailed(message: String) case backupFailed(message: String) case restoreError(message: String) case restoreFailed(message: String) } init(safeConfigManager: SafeConfigManagerProtocol, safeStore: SafeStore, safeApiService: SafeApiService) { self.safeConfigManager = safeConfigManager self.safeStore = safeStore self.safeApiService = safeApiService self.logger = ValidationLogger.shared() } //NSObject thereby not the whole SafeConfigManagerProtocol interface must be like @objc @objc convenience init(safeConfigManagerAsObject safeConfigManager: NSObject, safeStore: SafeStore, safeApiService: SafeApiService) { self.init(safeConfigManager: safeConfigManager as! SafeConfigManagerProtocol, safeStore: safeStore, safeApiService: safeApiService) } @objc var isActivated: Bool { get { if let key = self.safeConfigManager.getKey() { return key.count == self.safeStore.masterKeyLength } return false } } var isBackupRunning: Bool { get { return SafeManager.backupIsRunning } } @objc func activate(identity: String, password: String) throws { self.safeConfigManager.setKey(self.safeStore.createKey(identity: identity, password: password)) self.safeConfigManager.setIsTriggered(true) initTrigger() } @objc func activate(identity: String, password: String, customServer: String?, server: String?, maxBackupBytes: NSNumber?, retentionDays: NSNumber?) throws { if let key = self.safeStore.createKey(identity: identity, password: password) { try activate(key: key, customServer: customServer, server: server, maxBackupBytes: maxBackupBytes?.intValue, retentionDays: retentionDays?.intValue) } } func activate(key: [UInt8], customServer: String?, server: String?, maxBackupBytes: Int?, retentionDays: Int?) throws { if let customServer = customServer, let server = server { self.safeConfigManager.setKey(key) self.safeConfigManager.setCustomServer(customServer) self.safeConfigManager.setServer(server) self.safeConfigManager.setMaxBackupBytes(maxBackupBytes) self.safeConfigManager.setRetentionDays(retentionDays) } else { if let defaultServer = self.safeStore.getSafeDefaultServer(key: key) { let result = testServer(serverUrl: defaultServer) if let errorMessage = result.errorMessage { throw SafeError.activateFailed(message: "Test default server: \(errorMessage)") } else { self.safeConfigManager.setKey(key) self.safeConfigManager.setCustomServer(nil) self.safeConfigManager.setServer(defaultServer.absoluteString) self.safeConfigManager.setMaxBackupBytes(result.maxBackupBytes) self.safeConfigManager.setRetentionDays(result.retentionDays) } } } initTrigger() } @objc func deactivate() { if let observer = SafeManager.backupObserver { SafeManager.backupObserver = nil NotificationCenter.default.removeObserver(observer, name: Notification.Name(kSafeBackupTrigger), object: nil) } if let key = self.safeConfigManager.getKey(), let backupId = self.safeStore.getBackupId(key: key) { if let safeServer = self.safeStore.getSafeServer(key: key) { let safeServerAuth = self.safeStore.extractSafeServerAuth(server: safeServer) let safeBackupUrl = safeServerAuth.server.appendingPathComponent("backups/\(SafeStore.dataToHexString(backupId))") if let errorMessage = safeApiService.delete(server: safeBackupUrl, user: safeServerAuth.user, password: safeServerAuth.password) { self.logger.logString("Safe backup could not be deleted: \(errorMessage)") } } } self.safeConfigManager.setKey(nil) self.safeConfigManager.setCustomServer(nil) self.safeConfigManager.setServer(nil) self.safeConfigManager.setMaxBackupBytes(nil) self.safeConfigManager.setRetentionDays(nil) self.safeConfigManager.setLastBackup(nil) self.safeConfigManager.setLastChecksum(nil) self.safeConfigManager.setLastResult(nil) self.safeConfigManager.setLastAlertBackupFailed(nil) self.safeConfigManager.setBackupStartedAt(nil) self.safeConfigManager.setIsTriggered(false) DispatchQueue.main.async { self.setBackupReminder() } } func isPasswordBad(password: String) -> Bool { if password.count < 8 { return true } else if checkPasswordToRegEx(password: password) { return true } return checkPasswordToFile(password: password) } private func checkPasswordToFile(password: String) -> Bool { guard let filePath = Bundle.main.path(forResource: "bad_passwords", ofType: "txt"), let fileHandle = FileHandle(forReadingAtPath: filePath) else { return false } defer { fileHandle.closeFile() } let delimiter: Data = String(stringLiteral: "\n").data(using: .utf8)! let chunkSize = 4096 var isEof: Bool = false var lineStart: String = "" while !isEof { var position: Int = 0 let chunk = fileHandle.readData(ofLength: chunkSize) if chunk.count == 0 { isEof = true } //compare password with all lines within the chunk repeat { var line: String = "" if let range = chunk.subdata(in: position.. 0 { line.append(lineStart) lineStart = "" } line.append(String(data: chunk.subdata(in: position.. position { lineStart = String(data: chunk.subdata(in: position.. 0 && line == password { return true } } while chunk.count > position } return false } private func checkPasswordToRegEx(password: String) -> Bool { let checks = [ "(.)\\1+", //do not allow single repeating characters "^[0-9]{1,15}$"] //do not allow numbers only do { for check in checks { let regex = try NSRegularExpression(pattern: check, options: .caseInsensitive) let result = regex.matches(in: password, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSRange(location: 0, length: password.count)) //result must match once the whole password/string if result.count == 1 && result[0].range.location == 0 && result[0].range.length == password.count { return true } } } catch let error { print("regex faild to check password: \(error.localizedDescription)") } return false } static func isPasswordPatternValid(password: String, regExPattern: String) throws -> Bool { var regExMatches: Int = 0 let regEx = try NSRegularExpression(pattern: regExPattern) regExMatches = regEx.numberOfMatches(in: password, options: [], range: NSRange.init(location: 0, length: password.count)) return regExMatches == 1 } @objc func setBackupReminder() { // remove safe backup notification anyway let notificationKey = "safe-backup-notification" let oneDayInSeconds = 24 * 60 * 60 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [notificationKey]) DDLogNotice("Threema Safe: Reminder notification removed") // add new safe backup notification, if is Threema Safe activated and is set backup retention days if self.isActivated, let lastBackup = self.safeConfigManager.getLastBackup(), let retentionDays = self.safeConfigManager.getRetentionDays() { let notification = UNMutableNotificationContent() notification.title = BundleUtil.localizedString(forKey: "safe_setup_backup_title") notification.body = BundleUtil.localizedString(forKey: "safe_expired_notification") notification.categoryIdentifier = "SAFE_SETUP" notification.userInfo = ["threema": ["nil": "nil"], "key": notificationKey] var trigger: UNTimeIntervalNotificationTrigger? var fireDate = lastBackup.addingTimeInterval(TimeInterval(oneDayInSeconds * (retentionDays / 2))) if fireDate.timeIntervalSinceNow <= 0 { // Fire date is in the past fireDate = lastBackup.addingTimeInterval(TimeInterval(oneDayInSeconds * retentionDays)) if fireDate.timeIntervalSinceNow <= 0 { // Safe backup it outside of retention days let seconds = lastBackup.timeIntervalSinceNow let days = Double(exactly: seconds / Double(oneDayInSeconds))?.rounded(.up) notification.body = String(format: BundleUtil.localizedString(forKey: "safe_failed_notification"), abs(days!)) } else { trigger = UNTimeIntervalNotificationTrigger(timeInterval: fireDate.timeIntervalSinceNow, repeats: false) } } else { // Fire date is in the future trigger = UNTimeIntervalNotificationTrigger(timeInterval: fireDate.timeIntervalSinceNow, repeats: false) } let notificationRequest = UNNotificationRequest(identifier: notificationKey, content: notification, trigger: trigger) UNUserNotificationCenter.current().add(notificationRequest) { error in if let error = error { DDLogError("Threema Safe: Error adding reminder to fire at \(DateFormatter.getFullDate(for: fireDate)): \(error.localizedDescription)") } else { DDLogNotice("Threema Safe: Reminder notification added, fire at: \(DateFormatter.getFullDate(for: fireDate))") } } } } func testServer(serverUrl: URL) -> (errorMessage: String?, maxBackupBytes: Int?, retentionDays: Int?) { let safeServerAuth = self.safeStore.extractSafeServerAuth(server: serverUrl) let result = self.safeApiService.testServer(server: safeServerAuth.server, user: safeServerAuth.user, password: safeServerAuth.password) if let errorMessage = result.errorMessage { return (errorMessage: errorMessage, maxBackupBytes: nil, retentionDays: nil) } else { let parser = SafeJsonParser() guard let data = result.serverConfig, let config = parser.getSafeServerConfig(from: data) else { return (errorMessage: "Invalid response data", maxBackupBytes: nil, retentionDays: nil) } return (errorMessage: nil, maxBackupBytes: config.maxBackupBytes, retentionDays: config.retentionDays) } } /// Apply Threema Safe server it has changed @objc func applyServer(server: String?, username: String?, password: String?) { if self.isActivated { var newServerUrl: URL? if let customServer = server { newServerUrl = self.safeStore.composeSafeServerAuth(server: customServer, user: username, password: password) } else { newServerUrl = self.safeStore.getSafeDefaultServer(key: self.safeConfigManager.getKey()!) } if let newServerUrl = newServerUrl { if self.safeConfigManager.getServer() != newServerUrl.absoluteString { // Save Threema Safe server config and reset result and control config self.safeConfigManager.setCustomServer(server) self.safeConfigManager.setServer(newServerUrl.absoluteString) self.safeConfigManager.setMaxBackupBytes(nil) self.safeConfigManager.setRetentionDays(nil) self.safeConfigManager.setLastChecksum(nil) self.safeConfigManager.setBackupSize(nil) self.safeConfigManager.setBackupStartedAt(nil) self.safeConfigManager.setLastAlertBackupFailed(nil) self.safeConfigManager.setIsTriggered(true) self.safeConfigManager.setLastResult(nil) self.safeConfigManager.setLastBackup(nil) } } else { self.logger.logString("Error while apply Threema Safe server: could not calculate server") } } } private func startBackup(force: Bool, completionHandler: @escaping () -> Void) { self.backupCompletionHandler = completionHandler do { if let key = self.safeConfigManager.getKey(), let backupId = self.safeStore.getBackupId(key: key) { // get backup data and and its checksum if let data = self.safeStore.backupData() { self.checksum = self.safeStore.sha1(data: Data(data)) // do backup is forced or if data has changed or last backup (nearly) out of date if force || self.safeConfigManager.getLastChecksum() != self.checksum || self.safeStore.isDateOlderThenDays(date: self.safeConfigManager.getLastBackup(), days: self.safeConfigManager.getRetentionDays() ?? 180 / 2) { self.safeConfigManager.setBackupStartedAt(Date()) // test server and save its config if let safeServerUrl = self.safeStore.getSafeServer(key: key) { let safeServerAuth = self.safeStore.extractSafeServerAuth(server: safeServerUrl) let safeBackupUrl = safeServerAuth.server.appendingPathComponent("backups/\(SafeStore.dataToHexString(backupId))") let result = testServer(serverUrl: safeServerUrl) if let errorMessage = result.errorMessage { throw SafeError.backupFailed(message: errorMessage) } else { self.safeConfigManager.setMaxBackupBytes(result.maxBackupBytes) self.safeConfigManager.setRetentionDays(result.retentionDays) } // encrypt backup data and upload it let encryptedData = try self.safeStore.encryptBackupData(key: key, data: data) // set actual backup size anyway self.safeConfigManager.setBackupSize(Int64(encryptedData.count)) if encryptedData.count < self.safeConfigManager.getMaxBackupBytes() ?? 524288 { self.safeApiService.upload(backup: safeBackupUrl, user: safeServerAuth.user, password: safeServerAuth.password, encryptedData: encryptedData) { (data, errorMessage) in if let errorMessage = errorMessage { self.logger.logString(errorMessage) self.safeConfigManager.setLastResult(errorMessage.contains("Payload Too Large") ? BundleUtil.localizedString(forKey: "safe_upload_size_exceeded") : "\(BundleUtil.localizedString(forKey: "safe_upload_failed")!) (\(errorMessage))") } else { self.safeConfigManager.setLastChecksum(self.checksum) self.safeConfigManager.setLastBackup(Date()) self.safeConfigManager.setLastResult(BundleUtil.localizedString(forKey: "safe_successful")) self.safeConfigManager.setLastAlertBackupFailed(nil) } self.backupCompletionHandler!() } } else { throw SafeError.backupFailed(message: BundleUtil.localizedString(forKey: "safe_upload_size_exceeded")) } } else { throw SafeError.backupFailed(message: "Invalid safe server url") } // cancel background task here, because the upload it's a background task too BackgroundTaskManager.shared.cancelBackgroundTask(key: kSafeBackgroundTask) } else { self.backupCompletionHandler!() } } else { throw SafeError.backupFailed(message: "Missing private key") } } else { throw SafeStore.SafeError.invalidMasterKey } } catch SafeError.backupFailed(let message) { self.logger.logString(message) self.safeConfigManager.setLastResult("\(BundleUtil.localizedString(forKey: "safe_unsuccessful")!): \(message)") self.backupCompletionHandler!() } catch let error { self.logger.logString(error.localizedDescription) self.safeConfigManager.setLastResult("\(BundleUtil.localizedString(forKey: "safe_unsuccessful")!): \(error.localizedDescription)") self.backupCompletionHandler!() } } func startRestore(identity:String, password: String, customServer: String?, server: String?, restoreIdentityOnly: Bool, activateSafeAnyway: Bool, completionHandler: @escaping (SafeError?) -> Swift.Void) { if let key = self.safeStore.createKey(identity: identity, password: password), let backupId = self.safeStore.getBackupId(key: key) { var safeServerUrl: URL if let server = server, server.count > 0 { safeServerUrl = URL(string: server)! } else { safeServerUrl = self.safeStore.getSafeDefaultServer(key: key)! } let safeServerAuth = self.safeStore.extractSafeServerAuth(server: safeServerUrl) let backupUrl = safeServerAuth.server.appendingPathComponent("backups/\(SafeStore.dataToHexString(backupId))") var decryptedData: [UInt8]? do { let safeApiService = SafeApiService() let encryptedData = try safeApiService.download(backup: backupUrl, user: safeServerAuth.user, password: safeServerAuth.password) if encryptedData != nil { decryptedData = try self.safeStore.decryptBackupData(key: key, data: Array(encryptedData!)) try self.safeStore.restoreData(identity: identity, data: decryptedData!, onlyIdentity: restoreIdentityOnly, completionHandler: { (error) in if let error = error { switch error { case .restoreError(let message): completionHandler(SafeError.restoreError(message: message)) case .restoreFailed(let message): completionHandler(SafeError.restoreFailed(message: message)) default: break } } else { do { if (!restoreIdentityOnly || activateSafeAnyway) { //activate Threema Safe try self.activate(key: key, customServer: customServer, server: safeServerUrl.absoluteString, maxBackupBytes: nil, retentionDays: nil) } else { //show Threema Safe-Intro UserSettings.shared()?.safeIntroShown = false } //trigger backup NotificationCenter.default.post(name: NSNotification.Name(kSafeBackupTrigger), object: nil) completionHandler(nil) } catch { completionHandler(SafeError.restoreError(message: BundleUtil.localizedString(forKey: "safe_activation_failed"))) } } }) } } catch SafeApiService.SafeApiError.requestFailed(let message) { completionHandler(SafeError.restoreFailed(message: "\(BundleUtil.localizedString(forKey: "safe_no_backup_found")!) (\(message))")) } catch SafeStore.SafeError.restoreFailed(let message) { completionHandler(SafeError.restoreFailed(message: message)) if let decryptedData = decryptedData { // Save decrypted backup data into application documents folder, for analyzing failures _ = FileUtility.write(fileUrl: DocumentManager.applicationDocumentsDirectory()?.appendingPathComponent("safe-backup.json"), text: String(bytes: decryptedData, encoding: .utf8)!) } } catch { completionHandler(SafeError.restoreFailed(message: BundleUtil.localizedString(forKey: "safe_no_backup_found"))) } } else { completionHandler(SafeError.restoreFailed(message: BundleUtil.localizedString(forKey: "safe_no_backup_found"))) } } @objc func initTrigger() { DDLogVerbose("Threema Safe triggered") if isActivated { if SafeManager.backupObserver == nil { SafeManager.backupObserver = NotificationCenter.default.addObserver(forName: Notification.Name(kSafeBackupTrigger), object: nil, queue: nil) { (notification) in if !AppDelegate.shared().isAppInBackground() && self.isActivated { //start background task to give time to create backup file, if the app is going into background BackgroundTaskManager.shared.newBackgroundTask(key: kSafeBackgroundTask, timeout: 60, completionHandler: { if SafeManager.backupDelay != nil { SafeManager.backupDelay?.invalidate() } // set 5s delay timer to start backup (if delay time 0s, then force backup) var interval: Int = 5 if notification.object is Int { interval = notification.object as! Int } self.backupForce = interval == 0 //async is necessary if the call is already within an operation queue (like after setup completion) SafeManager.backupDelay = Timer.scheduledTimer(timeInterval: TimeInterval(interval), target: self, selector: #selector(self.trigger), userInfo: nil, repeats: false) }) } } } if self.safeConfigManager.getIsTriggered() || self.safeStore.isDateOlderThenDays(date: self.safeConfigManager.getLastBackup(), days: 1) { NotificationCenter.default.post(name: NSNotification.Name(kSafeBackupTrigger), object: nil) } // Show alert once a day, if is last successful backup older than 7 days if self.safeConfigManager.getLastResult() != BundleUtil.localizedString(forKey: "safe_successful") && self.safeConfigManager.getLastBackup() != nil && self.safeStore.isDateOlderThenDays(date: self.safeConfigManager.getLastBackup(), days: 7) { DDLogWarn("WARNING Threema Safe backup not successfully since 7 days or more") self.logger.logString("WARNING Threema Safe backup not successfully since 7 days or more") if self.safeStore.isDateOlderThenDays(date: self.safeConfigManager.getLastAlertBackupFailed(), days: 1) { if let topViewController = AppDelegate.shared()?.currentTopViewController(), let seconds = self.safeConfigManager.getLastBackup()?.timeIntervalSinceNow, let days = Double(exactly: seconds / 86400)?.rounded(FloatingPointRoundingRule.up) { self.safeConfigManager.setLastAlertBackupFailed(Date()) UIAlertTemplate.showAlert(owner: topViewController, title: BundleUtil.localizedString(forKey: "safe_setup_backup_title"), message: String(format: BundleUtil.localizedString(forKey: "safe_failed_notification"), abs(days))) } } } } } @objc private func trigger() { DispatchQueue(label: "backupProcess").async { //if forced, try to start backup immediately, otherwise when backup process is already running or last backup not older then a day then just mark as triggered SafeManager.backupProcessLock.sync { SafeManager.backupProcessStart = false if self.backupForce && SafeManager.backupIsRunning { self.safeConfigManager.setLastResult("\(NSLocalizedString("safe_unsuccessful", comment: "")): is already running") } else if !self.backupForce && (SafeManager.backupIsRunning || !self.safeStore.isDateOlderThenDays(date: self.safeConfigManager.getLastBackup(), days: 1)) { self.safeConfigManager.setIsTriggered(true) self.logger.logString("Safe backup just triggered") } else { SafeManager.backupProcessStart = true SafeManager.backupIsRunning = true self.safeConfigManager.setIsTriggered(false) } } if SafeManager.backupProcessStart { self.logger.logString("Safe backup start, force \(self.backupForce)") self.startBackup(force: self.backupForce) { SafeManager.backupProcessLock.sync { SafeManager.backupIsRunning = false BackgroundTaskManager.shared.cancelBackgroundTask(key: kSafeBackgroundTask) } DispatchQueue.main.async { self.setBackupReminder() NotificationCenter.default.post(name: NSNotification.Name(kSafeBackupUIRefresh), object: nil) } self.logger.logString("Safe backup completed") } } else { BackgroundTaskManager.shared.cancelBackgroundTask(key: kSafeBackgroundTask) } DispatchQueue.main.async { NotificationCenter.default.post(name: NSNotification.Name(kSafeBackupUIRefresh), object: nil) } } } }