This commit is contained in:
Isaac 2024-03-11 22:46:51 +04:00
parent 4cef442e92
commit af6f1da0ab
8 changed files with 519 additions and 15 deletions

View File

@ -705,6 +705,8 @@ private final class NotificationServiceHandler {
let _ = try? FileManager.default.createDirectory(atPath: logsPath, withIntermediateDirectories: true, attributes: nil) let _ = try? FileManager.default.createDirectory(atPath: logsPath, withIntermediateDirectories: true, attributes: nil)
setupSharedLogger(rootPath: logsPath, path: logsPath) setupSharedLogger(rootPath: logsPath, path: logsPath)
Logger.shared.log("NotificationService \(episode)", "Started handling notification")
initializeAccountManagement() initializeAccountManagement()

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalAuthentication import LocalAuthentication
import SwiftSignalKit import SwiftSignalKit
import Security
public enum LocalAuthBiometricAuthentication { public enum LocalAuthBiometricAuthentication {
case touchId case touchId
@ -8,10 +9,65 @@ public enum LocalAuthBiometricAuthentication {
} }
public struct LocalAuth { 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<CFError>?
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<CFError>?
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() let context = LAContext()
if context.canEvaluatePolicy(LAPolicy(rawValue: Int(kLAPolicyDeviceOwnerAuthenticationWithBiometrics))!, error: nil) { if context.canEvaluatePolicy(LAPolicy(rawValue: Int(kLAPolicyDeviceOwnerAuthenticationWithBiometrics))!, error: nil) {
#if swift(>=5.9)
switch context.biometryType { switch context.biometryType {
case .faceID, .opticID: case .faceID, .opticID:
return .faceId return .faceId
@ -22,22 +78,10 @@ public struct LocalAuth {
@unknown default: @unknown default:
return nil 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 { } else {
return nil return nil
} }
}() }
public static let evaluatedPolicyDomainState: Data? = { public static let evaluatedPolicyDomainState: Data? = {
let context = LAContext() 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<CFError>?
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
}
} }

View File

@ -286,6 +286,7 @@ private enum PreferencesKeyValues: Int32 {
case displaySavedChatsAsTopics = 35 case displaySavedChatsAsTopics = 35
case shortcutMessages = 37 case shortcutMessages = 37
case timezoneList = 38 case timezoneList = 38
case botBiometricsState = 39
} }
public func applicationSpecificPreferencesKey(_ value: Int32) -> ValueBoxKey { public func applicationSpecificPreferencesKey(_ value: Int32) -> ValueBoxKey {
@ -481,6 +482,13 @@ public struct PreferencesKeys {
key.setInt32(0, value: PreferencesKeyValues.timezoneList.rawValue) key.setInt32(0, value: PreferencesKeyValues.timezoneList.rawValue)
return key 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 { private enum SharedDataKeyValues: Int32 {

View File

@ -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
}
}
}
} }
} }

View File

@ -319,3 +319,42 @@ func _internal_invokeBotCustomMethod(postbox: Postbox, network: Network, botId:
|> castError(InvokeBotCustomMethodError.self) |> castError(InvokeBotCustomMethodError.self)
|> switchToLatest |> 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<Never, NoError> {
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
}

View File

@ -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()
}
} }
} }

View File

@ -33,6 +33,7 @@ swift_library(
"//submodules/CheckNode:CheckNode", "//submodules/CheckNode:CheckNode",
"//submodules/Markdown:Markdown", "//submodules/Markdown:Markdown",
"//submodules/TextFormat:TextFormat", "//submodules/TextFormat:TextFormat",
"//submodules/LocalAuth",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -23,6 +23,7 @@ import PromptUI
import PhoneNumberFormat import PhoneNumberFormat
import QrCodeUI import QrCodeUI
import InstantPageUI import InstantPageUI
import LocalAuth
private let durgerKingBotIds: [Int64] = [5104055776, 2200339955] 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 { if let json = json, let isVisible = json["is_visible"] as? Bool {
self.controller?.hasSettings = isVisible 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: default:
break break
} }
@ -1392,6 +1405,230 @@ public final class WebAppController: ViewController, AttachmentContainable {
self.webView?.sendEvent(name: "custom_method_invoked", data: paramsString) 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 { fileprivate var controllerNode: Node {