diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index f79918f225..20e71ee2d6 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -705,6 +705,8 @@ private final class NotificationServiceHandler { let _ = try? FileManager.default.createDirectory(atPath: logsPath, withIntermediateDirectories: true, attributes: nil) setupSharedLogger(rootPath: logsPath, path: logsPath) + + Logger.shared.log("NotificationService \(episode)", "Started handling notification") initializeAccountManagement() diff --git a/submodules/LocalAuth/Sources/LocalAuth.swift b/submodules/LocalAuth/Sources/LocalAuth.swift index 2ea1dcd3e6..7796fc355e 100644 --- a/submodules/LocalAuth/Sources/LocalAuth.swift +++ b/submodules/LocalAuth/Sources/LocalAuth.swift @@ -1,6 +1,7 @@ import Foundation import LocalAuthentication import SwiftSignalKit +import Security public enum LocalAuthBiometricAuthentication { case touchId @@ -8,10 +9,65 @@ public enum LocalAuthBiometricAuthentication { } public struct LocalAuth { - public static let biometricAuthentication: LocalAuthBiometricAuthentication? = { + private static let customKeyIdPrefix = "$#_".data(using: .utf8)! + + public enum DecryptionResult { + public enum Error { + case cancelled + case generic + } + + case result(Data) + case error(Error) + } + + public final class PrivateKey { + private let privateKey: SecKey + private let publicKey: SecKey + public let publicKeyRepresentation: Data + + fileprivate init(privateKey: SecKey, publicKey: SecKey, publicKeyRepresentation: Data) { + self.privateKey = privateKey + self.publicKey = publicKey + self.publicKeyRepresentation = publicKeyRepresentation + } + + public func encrypt(data: Data) -> Data? { + var error: Unmanaged? + let cipherText = SecKeyCreateEncryptedData(self.publicKey, .eciesEncryptionCofactorVariableIVX963SHA512AESGCM, data as CFData, &error) + if let error { + error.release() + } + guard let cipherText else { + return nil + } + + let result = cipherText as Data + return result + } + + public func decrypt(data: Data) -> DecryptionResult { + var maybeError: Unmanaged? + let plainText = SecKeyCreateDecryptedData(self.privateKey, .eciesEncryptionCofactorVariableIVX963SHA512AESGCM, data as CFData, &maybeError) + let error = maybeError?.takeRetainedValue() + + guard let plainText else { + if let error { + if CFErrorGetCode(error) == -2 { + return .error(.cancelled) + } + } + return .error(.generic) + } + + let result = plainText as Data + return .result(result) + } + } + + public static var biometricAuthentication: LocalAuthBiometricAuthentication? { let context = LAContext() if context.canEvaluatePolicy(LAPolicy(rawValue: Int(kLAPolicyDeviceOwnerAuthenticationWithBiometrics))!, error: nil) { - #if swift(>=5.9) switch context.biometryType { case .faceID, .opticID: return .faceId @@ -22,22 +78,10 @@ public struct LocalAuth { @unknown default: return nil } - #else - switch context.biometryType { - case .faceID://, .opticID: - return .faceId - case .touchID: - return .touchId - case .none: - return nil - @unknown default: - return nil - } - #endif } else { return nil } - }() + } public static let evaluatedPolicyDomainState: Data? = { let context = LAContext() @@ -78,4 +122,145 @@ public struct LocalAuth { } } } + + private static func bundleSeedId() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrAccount as String: "bundleSeedID", + kSecAttrService as String: "", + kSecReturnAttributes as String: true + ] + var result: CFTypeRef? + var status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + status = SecItemAdd(query as CFDictionary, &result) + } + if status != errSecSuccess { + return nil + } + guard let result = result else { + return nil + } + if CFGetTypeID(result) != CFDictionaryGetTypeID() { + return nil + } + guard let resultDict = (result as! CFDictionary) as? [String: Any] else { + return nil + } + guard let accessGroup = resultDict[kSecAttrAccessGroup as String] as? String else { + return nil + } + let components = accessGroup.components(separatedBy: ".") + guard let seedId = components.first else { + return nil + } + return seedId; + } + + public static func getPrivateKey(baseAppBundleId: String, keyId: Data) -> PrivateKey? { + guard let bundleSeedId = self.bundleSeedId() else { + return nil + } + + let applicationTag = customKeyIdPrefix + keyId + let accessGroup = "\(bundleSeedId).\(baseAppBundleId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassKey as String, + kSecAttrApplicationTag as String: applicationTag, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom as String, + kSecAttrAccessGroup as String: accessGroup, + kSecReturnRef as String: true + ] + + var maybePrivateKey: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &maybePrivateKey) + if status != errSecSuccess { + return nil + } + guard let maybePrivateKey else { + return nil + } + if CFGetTypeID(maybePrivateKey) != SecKeyGetTypeID() { + return nil + } + let privateKey = maybePrivateKey as! SecKey + + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + return nil + } + guard let publicKeyRepresentation = SecKeyCopyExternalRepresentation(publicKey, nil) else { + return nil + } + + let result = PrivateKey(privateKey: privateKey, publicKey: publicKey, publicKeyRepresentation: publicKeyRepresentation as Data) + + return result + } + + public static func removePrivateKey(baseAppBundleId: String, keyId: Data) -> Bool { + guard let bundleSeedId = self.bundleSeedId() else { + return false + } + + let applicationTag = customKeyIdPrefix + keyId + let accessGroup = "\(bundleSeedId).\(baseAppBundleId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassKey as String, + kSecAttrApplicationTag as String: applicationTag, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom as String, + kSecAttrIsPermanent as String: true, + kSecAttrAccessGroup as String: accessGroup + ] + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess { + return false + } + return true + } + + public static func addPrivateKey(baseAppBundleId: String, keyId: Data) -> PrivateKey? { + guard let bundleSeedId = self.bundleSeedId() else { + return nil + } + + let applicationTag = customKeyIdPrefix + keyId + let accessGroup = "\(bundleSeedId).\(baseAppBundleId)" + + guard let access = SecAccessControlCreateWithFlags(kCFAllocatorDefault, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, [.userPresence, .privateKeyUsage], nil) else { + return nil + } + + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom as String, + kSecAttrKeySizeInBits as String: 256 as NSNumber, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave as String, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: applicationTag, + kSecAttrAccessControl as String: access, + kSecAttrAccessGroup as String: accessGroup, + ] as [String: Any] + ] + var error: Unmanaged? + let maybePrivateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) + if let error { + error.release() + } + guard let privateKey = maybePrivateKey else { + return nil + } + + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + return nil + } + guard let publicKeyRepresentation = SecKeyCopyExternalRepresentation(publicKey, nil) else { + return nil + } + + let result = PrivateKey(privateKey: privateKey, publicKey: publicKey, publicKeyRepresentation: publicKeyRepresentation as Data) + return result + } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift index 958ac84482..afe17a9427 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift @@ -286,6 +286,7 @@ private enum PreferencesKeyValues: Int32 { case displaySavedChatsAsTopics = 35 case shortcutMessages = 37 case timezoneList = 38 + case botBiometricsState = 39 } public func applicationSpecificPreferencesKey(_ value: Int32) -> ValueBoxKey { @@ -481,6 +482,13 @@ public struct PreferencesKeys { key.setInt32(0, value: PreferencesKeyValues.timezoneList.rawValue) return key } + + public static func botBiometricsState(peerId: PeerId) -> ValueBoxKey { + let key = ValueBoxKey(length: 4 + 8) + key.setInt32(0, value: PreferencesKeyValues.botBiometricsState.rawValue) + key.setInt64(4, value: peerId.toInt64()) + return key + } } private enum SharedDataKeyValues: Int32 { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift index a036a33a41..9ed2063e1b 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift @@ -1584,5 +1584,33 @@ public extension TelegramEngine.EngineData.Item { } } } + + public struct BotBiometricsState: TelegramEngineDataItem, TelegramEngineMapKeyDataItem, PostboxViewDataItem { + public typealias Result = TelegramBotBiometricsState + + fileprivate var id: EnginePeer.Id + public var mapKey: EnginePeer.Id { + return self.id + } + + public init(id: EnginePeer.Id) { + self.id = id + } + + var key: PostboxViewKey { + return .preferences(keys: Set([PreferencesKeys.botBiometricsState(peerId: self.id)])) + } + + func extract(view: PostboxView) -> Result { + guard let view = view as? PreferencesView else { + preconditionFailure() + } + if let state = view.values[PreferencesKeys.botBiometricsState(peerId: self.id)]?.get(TelegramBotBiometricsState.self) { + return state + } else { + return TelegramBotBiometricsState.default + } + } + } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift index 7d2b62e30e..55e5779f21 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift @@ -319,3 +319,42 @@ func _internal_invokeBotCustomMethod(postbox: Postbox, network: Network, botId: |> castError(InvokeBotCustomMethodError.self) |> switchToLatest } + +public struct TelegramBotBiometricsState: Codable, Equatable { + public struct OpaqueToken: Codable, Equatable { + public let publicKey: Data + public let data: Data + + public init(publicKey: Data, data: Data) { + self.publicKey = publicKey + self.data = data + } + } + + public var accessRequested: Bool + public var accessGranted: Bool + public var opaqueToken: OpaqueToken? + + public static var `default`: TelegramBotBiometricsState { + return TelegramBotBiometricsState( + accessRequested: false, + accessGranted: false, + opaqueToken: nil + ) + } + + public init(accessRequested: Bool, accessGranted: Bool, opaqueToken: OpaqueToken?) { + self.accessRequested = accessRequested + self.accessGranted = accessGranted + self.opaqueToken = opaqueToken + } +} + +func _internal_updateBotBiometricsState(account: Account, peerId: EnginePeer.Id, update: @escaping (TelegramBotBiometricsState) -> TelegramBotBiometricsState) -> Signal { + return account.postbox.transaction { transaction -> Void in + let previousState = transaction.getPreferencesEntry(key: PreferencesKeys.botBiometricsState(peerId: peerId))?.get(TelegramBotBiometricsState.self) ?? TelegramBotBiometricsState.default + + transaction.setPreferencesEntry(key: PreferencesKeys.botBiometricsState(peerId: peerId), value: PreferencesEntry(update(previousState))) + } + |> ignoreValues +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift index 38876a16b8..6980bbab07 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift @@ -1490,6 +1490,10 @@ public extension TelegramEngine { } } } + + public func updateBotBiometricsState(peerId: EnginePeer.Id, update: @escaping (TelegramBotBiometricsState) -> TelegramBotBiometricsState) { + let _ = _internal_updateBotBiometricsState(account: self.account, peerId: peerId, update: update).startStandalone() + } } } diff --git a/submodules/WebUI/BUILD b/submodules/WebUI/BUILD index 878e6bcb76..3973bdc9fc 100644 --- a/submodules/WebUI/BUILD +++ b/submodules/WebUI/BUILD @@ -33,6 +33,7 @@ swift_library( "//submodules/CheckNode:CheckNode", "//submodules/Markdown:Markdown", "//submodules/TextFormat:TextFormat", + "//submodules/LocalAuth", ], visibility = [ "//visibility:public", diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 642b3d682d..8ee017bd74 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -23,6 +23,7 @@ import PromptUI import PhoneNumberFormat import QrCodeUI import InstantPageUI +import LocalAuth private let durgerKingBotIds: [Int64] = [5104055776, 2200339955] @@ -1064,6 +1065,18 @@ public final class WebAppController: ViewController, AttachmentContainable { if let json = json, let isVisible = json["is_visible"] as? Bool { self.controller?.hasSettings = isVisible } + case "web_app_biometry_get_info": + self.sendBiometryInfoReceivedEvent() + case "web_app_biometry_request_access": + self.requestBiometryAccess() + case "web_app_biometry_request_auth": + self.requestBiometryAuth() + case "web_app_biometry_update_token": + var tokenData: Data? + if let json, let tokenDataValue = json["token"] as? Data { + tokenData = tokenDataValue + } + self.requestBiometryUpdateToken(tokenData: tokenData) default: break } @@ -1392,6 +1405,230 @@ public final class WebAppController: ViewController, AttachmentContainable { self.webView?.sendEvent(name: "custom_method_invoked", data: paramsString) }) } + + fileprivate func sendBiometryInfoReceivedEvent() { + guard let controller = self.controller else { + return + } + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.BotBiometricsState(id: controller.botId) + ) + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return + } + + var data: [String: Any] = [:] + if let biometricAuthentication = LocalAuth.biometricAuthentication { + data["available"] = true + switch biometricAuthentication { + case .faceId: + data["type"] = "face" + case .touchId: + data["type"] = "finger" + } + data["access_requested"] = state.accessRequested + data["access_granted"] = state.accessGranted + data["token_saved"] = state.opaqueToken != nil + } else { + data["available"] = false + } + + guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { + return + } + guard let jsonDataString = String(data: jsonData, encoding: .utf8) else { + return + } + self.webView?.sendEvent(name: "biometry_info_received", data: jsonDataString) + }) + } + + fileprivate func requestBiometryAccess() { + guard let controller = self.controller else { + return + } + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: controller.botId), + TelegramEngine.EngineData.Item.Peer.BotBiometricsState(id: controller.botId) + ) + |> deliverOnMainQueue).start(next: { [weak self] botPeer, state in + guard let self, let botPeer, let controller = self.controller else { + return + } + + if state.accessRequested { + self.sendBiometryInfoReceivedEvent() + return + } + + let updateAccessGranted: (Bool) -> Void = { [weak self] granted in + guard let self else { + return + } + + self.context.engine.peers.updateBotBiometricsState(peerId: botPeer.id, update: { state in + var state = state + state.accessRequested = true + state.accessGranted = granted + return state + }) + + self.sendBiometryInfoReceivedEvent() + } + + //TODO:localize + let alertText = "Do you want to allow \(botPeer.compactDisplayTitle) to use Face ID?" + controller.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: nil, text: alertText, actions: [ + TextAlertAction(type: .genericAction, title: self.presentationData.strings.Common_No, action: { + updateAccessGranted(false) + }), + TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_Yes, action: { + updateAccessGranted(true) + }) + ], parseMarkdown: false), in: .window(.root)) + }) + } + + fileprivate func requestBiometryAuth() { + guard let controller = self.controller else { + return + } + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: controller.botId), + TelegramEngine.EngineData.Item.Peer.BotBiometricsState(id: controller.botId) + ) + |> deliverOnMainQueue).start(next: { [weak self] botPeer, state in + guard let self else { + return + } + + if state.accessRequested && state.accessGranted { + guard let controller = self.controller else { + return + } + guard let keyId = "A\(UInt64(bitPattern: self.context.account.id.int64))WebBot\(UInt64(bitPattern: controller.botId.toInt64()))".data(using: .utf8) else { + return + } + let appBundleId = self.context.sharedContext.applicationBindings.appBundleId + + Thread { [weak self] in + var key = LocalAuth.getPrivateKey(baseAppBundleId: appBundleId, keyId: keyId) + if key == nil { + key = LocalAuth.addPrivateKey(baseAppBundleId: appBundleId, keyId: keyId) + } + + let decryptedData: LocalAuth.DecryptionResult + if let key { + if let encryptedData = state.opaqueToken { + if encryptedData.publicKey == key.publicKeyRepresentation { + decryptedData = key.decrypt(data: encryptedData.data) + } else { + // The local keychain has been reset + if let emptyEncryptedData = key.encrypt(data: Data()) { + decryptedData = key.decrypt(data: emptyEncryptedData) + } else { + decryptedData = .error(.generic) + } + } + } else { + if let emptyEncryptedData = key.encrypt(data: Data()) { + decryptedData = key.decrypt(data: emptyEncryptedData) + } else { + decryptedData = .error(.generic) + } + } + } else { + decryptedData = .error(.generic) + } + + DispatchQueue.main.async { + guard let self else { + return + } + + switch decryptedData { + case let .result(token): + self.sendBiometryAuthResult(isAuthorized: true, tokenData: state.opaqueToken != nil ? token : nil) + case .error: + self.sendBiometryAuthResult(isAuthorized: false, tokenData: nil) + } + } + }.start() + } else { + self.sendBiometryAuthResult(isAuthorized: false, tokenData: nil) + } + }) + } + + fileprivate func sendBiometryAuthResult(isAuthorized: Bool, tokenData: Data?) { + var data: [String: Any] = [:] + data["status"] = isAuthorized ? "authorized" : "failed" + if isAuthorized { + if let tokenData { + data["token"] = tokenData + } else { + data["token"] = Data() + } + } + + guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { + return + } + guard let jsonDataString = String(data: jsonData, encoding: .utf8) else { + return + } + self.webView?.sendEvent(name: "biometry_auth_requested", data: jsonDataString) + } + + fileprivate func requestBiometryUpdateToken(tokenData: Data?) { + guard let controller = self.controller else { + return + } + guard let keyId = "A\(UInt64(bitPattern: self.context.account.id.int64))WebBot\(UInt64(bitPattern: controller.botId.toInt64()))".data(using: .utf8) else { + return + } + + if let tokenData { + let appBundleId = self.context.sharedContext.applicationBindings.appBundleId + Thread { [weak self] in + var key = LocalAuth.getPrivateKey(baseAppBundleId: appBundleId, keyId: keyId) + if key == nil { + key = LocalAuth.addPrivateKey(baseAppBundleId: appBundleId, keyId: keyId) + } + + var encryptedData: TelegramBotBiometricsState.OpaqueToken? + if let key { + if let result = key.encrypt(data: tokenData) { + encryptedData = TelegramBotBiometricsState.OpaqueToken( + publicKey: key.publicKeyRepresentation, + data: result + ) + } + } + + DispatchQueue.main.async { + guard let self else { + return + } + + if let encryptedData { + self.context.engine.peers.updateBotBiometricsState(peerId: controller.botId, update: { state in + var state = state + state.opaqueToken = encryptedData + return state + }) + } + } + }.start() + } else { + self.context.engine.peers.updateBotBiometricsState(peerId: controller.botId, update: { state in + var state = state + state.opaqueToken = nil + return state + }) + } + } } fileprivate var controllerNode: Node {