| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- // Copyright 2023 Google LLC
- //
- // Licensed under the Apache License, Version 2.0 (the "License");
- // you may not use this file except in compliance with the License.
- // You may obtain a copy of the License at
- //
- // http://www.apache.org/licenses/LICENSE-2.0
- //
- // Unless required by applicable law or agreed to in writing, software
- // distributed under the License is distributed on an "AS IS" BASIS,
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- // See the License for the specific language governing permissions and
- // limitations under the License.
- import FirebaseCoreExtension
- import FirebaseCoreInternal
- import Foundation
- /// The prefix string for keychain item account attribute before the key.
- ///
- /// A number "1" is encoded in the prefix in case we need to upgrade the scheme in future.
- private let kAccountPrefix = "firebase_auth_1_"
- /// The utility class to manipulate data in iOS Keychain.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- final class AuthKeychainServices: Sendable {
- /// The name of the keychain service.
- private let service: String
- private let keychainStorage: AuthKeychainStorage
- // MARK: - Internal methods for shared keychain operations
- required init(service: String = "Unset service", storage: AuthKeychainStorage) {
- self.service = service
- keychainStorage = storage
- }
- /// Get the item from keychain by given query.
- /// - Parameter query: The query to query the keychain.
- /// - Returns: The item of the given query. `nil` if it doesn't exist.
- func getItem(query: [String: Any]) throws -> Data? {
- var mutableQuery = query
- mutableQuery[kSecReturnData as String] = true
- mutableQuery[kSecReturnAttributes as String] = true
- // Using a match limit of 2 means that we can check whether more than one
- // item is returned by the query.
- mutableQuery[kSecMatchLimit as String] = 2
- var result: AnyObject?
- let status = keychainStorage.get(query: mutableQuery, result: &result)
- if let items = result as? [[String: Any]], status == noErr {
- if items.count != 1 {
- throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
- }
- return items[0][kSecValueData as String] as? Data
- }
- if status == errSecItemNotFound {
- return nil
- } else {
- throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
- }
- }
- /// Set the item into keychain with given query.
- /// - Parameter item: The item to be added into keychain.
- /// - Parameter query: The query to query the keychain.
- /// - Returns: Whether the operation succeed.
- func setItem(_ item: Data, withQuery query: [String: Any]) throws {
- let status: OSStatus
- let function: String
- if try (getItem(query: query)) != nil {
- let attributes: [String: Any] = [kSecValueData as String: item]
- status = keychainStorage.update(query: query, attributes: attributes)
- function = "SecItemUpdate"
- } else {
- var queryWithItem = query
- queryWithItem[kSecValueData as String] = item
- status = keychainStorage.add(query: queryWithItem)
- function = "SecItemAdd"
- }
- if status == noErr {
- return
- }
- throw AuthErrorUtils.keychainError(function: function, status: status)
- }
- /// Remove the item with given queryfrom keychain.
- /// - Parameter query: The query to query the keychain.
- func removeItem(query: [String: Any]) throws {
- let status = keychainStorage.delete(query: query)
- if status == noErr || status == errSecItemNotFound {
- return
- }
- throw AuthErrorUtils.keychainError(function: "SecItemDelete", status: status)
- }
- /// Indicates whether or not this class knows that the legacy item for a particular key has
- /// been deleted.
- ///
- /// This dictionary is to avoid unnecessary keychain operations against legacy items.
- private let legacyEntryDeletedForKey = UnfairLock<Set<String>>([])
- func data(forKey key: String) throws -> Data? {
- if let data = try getItemLegacy(query: genericPasswordQuery(key: key)) {
- return data
- }
- // Check for legacy form.
- if legacyEntryDeletedForKey.value().contains(key) {
- return nil
- }
- if let data = try getItemLegacy(query: legacyGenericPasswordQuery(key: key)) {
- // Move the data to current form.
- try setData(data, forKey: key)
- deleteLegacyItem(key: key)
- return data
- } else {
- // Mark legacy data as non-existing so we don't have to query it again.
- legacyEntryDeletedForKey.withLock { $0.insert(key) }
- return nil
- }
- }
- func setData(_ data: Data, forKey key: String) throws {
- try setItemLegacy(data, withQuery: genericPasswordQuery(key: key))
- }
- func removeData(forKey key: String) throws {
- try removeItem(query: genericPasswordQuery(key: key))
- // Legacy form item, if exists, also needs to be removed, otherwise it will be exposed when
- // current form item is removed, leading to incorrect semantics.
- deleteLegacyItem(key: key)
- }
- // MARK: - Internal methods for non-sharing keychain operations
- // TODO: This function can go away in favor of `getItem` if we can delete the legacy processing.
- func getItemLegacy(query: [String: Any]) throws -> Data? {
- var returningQuery = query
- returningQuery[kSecReturnData as String] = true
- returningQuery[kSecReturnAttributes as String] = true
- // Using a match limit of 2 means that we can check whether there is more than one item.
- // If we used a match limit of 1 we would never find out.
- returningQuery[kSecMatchLimit as String] = 2
- var result: AnyObject?
- let status = keychainStorage.get(query: returningQuery, result: &result)
- if let items = result as? [[String: Any]], status == noErr {
- if items.isEmpty {
- // The keychain query returned no error, but there were no items found.
- throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
- } else if items.count > 1 {
- // More than one keychain item was found, all but the first will be ignored.
- FirebaseLogger.log(
- level: .warning,
- service: "[FirebaseAuth]",
- code: "I-AUT000005",
- message: "Keychain query returned multiple results, all but the first will be ignored: \(items)"
- )
- }
- // Return the non-legacy item.
- for item in items {
- if item[kSecAttrService as String] != nil {
- return item[kSecValueData as String] as? Data
- }
- }
- // If they were all legacy items, just return the first one.
- // This should not happen, since only one account should be
- // stored.
- return items[0][kSecValueData as String] as? Data
- }
- if status == errSecItemNotFound {
- return nil
- } else {
- throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
- }
- }
- // TODO: This function can go away in favor of `setItem` if we can delete the legacy processing.
- func setItemLegacy(_ item: Data, withQuery query: [String: Any]) throws {
- let attributes: [String: Any] = [
- kSecValueData as String: item,
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
- ]
- let combined = attributes.merging(query, uniquingKeysWith: { _, last in last })
- var hasItem = false
- var status = keychainStorage.add(query: combined)
- if status == errSecDuplicateItem {
- hasItem = true
- status = keychainStorage.update(query: query, attributes: attributes)
- }
- if status == noErr {
- return
- }
- let function = hasItem ? "SecItemUpdate" : "SecItemAdd"
- throw AuthErrorUtils.keychainError(function: function, status: status)
- }
- /// Deletes legacy item from the keychain if it is not already known to be deleted.
- /// - Parameter key: The key for the item.
- private func deleteLegacyItem(key: String) {
- if legacyEntryDeletedForKey.value().contains(key) {
- return
- }
- let query = legacyGenericPasswordQuery(key: key)
- keychainStorage.delete(query: query)
- legacyEntryDeletedForKey.withLock { $0.insert(key) }
- }
- /// Returns a keychain query of generic password to be used to manipulate key'ed value.
- /// - Parameter key: The key for the value being manipulated, used as the account field in the
- /// query.
- private func genericPasswordQuery(key: String) -> [String: Any] {
- if key.isEmpty {
- fatalError("The key cannot be empty.")
- }
- var query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrAccount as String: kAccountPrefix + key,
- kSecAttrService as String: service,
- ]
- query[kSecUseDataProtectionKeychain as String] = true
- return query
- }
- /// Returns a keychain query of generic password without service field, which is used by
- /// previous version of this class .
- /// - Parameter key: The key for the value being manipulated, used as the account field in the
- /// query.
- private func legacyGenericPasswordQuery(key: String) -> [String: Any] {
- [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrAccount as String: key,
- ]
- }
- }
|