AuthKeychainServices.swift 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. // Copyright 2023 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import FirebaseCoreExtension
  15. import FirebaseCoreInternal
  16. import Foundation
  17. /// The prefix string for keychain item account attribute before the key.
  18. ///
  19. /// A number "1" is encoded in the prefix in case we need to upgrade the scheme in future.
  20. private let kAccountPrefix = "firebase_auth_1_"
  21. /// The utility class to manipulate data in iOS Keychain.
  22. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  23. final class AuthKeychainServices: Sendable {
  24. /// The name of the keychain service.
  25. private let service: String
  26. private let keychainStorage: AuthKeychainStorage
  27. // MARK: - Internal methods for shared keychain operations
  28. required init(service: String = "Unset service", storage: AuthKeychainStorage) {
  29. self.service = service
  30. keychainStorage = storage
  31. }
  32. /// Get the item from keychain by given query.
  33. /// - Parameter query: The query to query the keychain.
  34. /// - Returns: The item of the given query. `nil` if it doesn't exist.
  35. func getItem(query: [String: Any]) throws -> Data? {
  36. var mutableQuery = query
  37. mutableQuery[kSecReturnData as String] = true
  38. mutableQuery[kSecReturnAttributes as String] = true
  39. // Using a match limit of 2 means that we can check whether more than one
  40. // item is returned by the query.
  41. mutableQuery[kSecMatchLimit as String] = 2
  42. var result: AnyObject?
  43. let status = keychainStorage.get(query: mutableQuery, result: &result)
  44. if let items = result as? [[String: Any]], status == noErr {
  45. if items.count != 1 {
  46. throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
  47. }
  48. return items[0][kSecValueData as String] as? Data
  49. }
  50. if status == errSecItemNotFound {
  51. return nil
  52. } else {
  53. throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
  54. }
  55. }
  56. /// Set the item into keychain with given query.
  57. /// - Parameter item: The item to be added into keychain.
  58. /// - Parameter query: The query to query the keychain.
  59. /// - Returns: Whether the operation succeed.
  60. func setItem(_ item: Data, withQuery query: [String: Any]) throws {
  61. let status: OSStatus
  62. let function: String
  63. if try (getItem(query: query)) != nil {
  64. let attributes: [String: Any] = [kSecValueData as String: item]
  65. status = keychainStorage.update(query: query, attributes: attributes)
  66. function = "SecItemUpdate"
  67. } else {
  68. var queryWithItem = query
  69. queryWithItem[kSecValueData as String] = item
  70. status = keychainStorage.add(query: queryWithItem)
  71. function = "SecItemAdd"
  72. }
  73. if status == noErr {
  74. return
  75. }
  76. throw AuthErrorUtils.keychainError(function: function, status: status)
  77. }
  78. /// Remove the item with given queryfrom keychain.
  79. /// - Parameter query: The query to query the keychain.
  80. func removeItem(query: [String: Any]) throws {
  81. let status = keychainStorage.delete(query: query)
  82. if status == noErr || status == errSecItemNotFound {
  83. return
  84. }
  85. throw AuthErrorUtils.keychainError(function: "SecItemDelete", status: status)
  86. }
  87. /// Indicates whether or not this class knows that the legacy item for a particular key has
  88. /// been deleted.
  89. ///
  90. /// This dictionary is to avoid unnecessary keychain operations against legacy items.
  91. private let legacyEntryDeletedForKey = UnfairLock<Set<String>>([])
  92. func data(forKey key: String) throws -> Data? {
  93. if let data = try getItemLegacy(query: genericPasswordQuery(key: key)) {
  94. return data
  95. }
  96. // Check for legacy form.
  97. if legacyEntryDeletedForKey.value().contains(key) {
  98. return nil
  99. }
  100. if let data = try getItemLegacy(query: legacyGenericPasswordQuery(key: key)) {
  101. // Move the data to current form.
  102. try setData(data, forKey: key)
  103. deleteLegacyItem(key: key)
  104. return data
  105. } else {
  106. // Mark legacy data as non-existing so we don't have to query it again.
  107. legacyEntryDeletedForKey.withLock { $0.insert(key) }
  108. return nil
  109. }
  110. }
  111. func setData(_ data: Data, forKey key: String) throws {
  112. try setItemLegacy(data, withQuery: genericPasswordQuery(key: key))
  113. }
  114. func removeData(forKey key: String) throws {
  115. try removeItem(query: genericPasswordQuery(key: key))
  116. // Legacy form item, if exists, also needs to be removed, otherwise it will be exposed when
  117. // current form item is removed, leading to incorrect semantics.
  118. deleteLegacyItem(key: key)
  119. }
  120. // MARK: - Internal methods for non-sharing keychain operations
  121. // TODO: This function can go away in favor of `getItem` if we can delete the legacy processing.
  122. func getItemLegacy(query: [String: Any]) throws -> Data? {
  123. var returningQuery = query
  124. returningQuery[kSecReturnData as String] = true
  125. returningQuery[kSecReturnAttributes as String] = true
  126. // Using a match limit of 2 means that we can check whether there is more than one item.
  127. // If we used a match limit of 1 we would never find out.
  128. returningQuery[kSecMatchLimit as String] = 2
  129. var result: AnyObject?
  130. let status = keychainStorage.get(query: returningQuery, result: &result)
  131. if let items = result as? [[String: Any]], status == noErr {
  132. if items.isEmpty {
  133. // The keychain query returned no error, but there were no items found.
  134. throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
  135. } else if items.count > 1 {
  136. // More than one keychain item was found, all but the first will be ignored.
  137. FirebaseLogger.log(
  138. level: .warning,
  139. service: "[FirebaseAuth]",
  140. code: "I-AUT000005",
  141. message: "Keychain query returned multiple results, all but the first will be ignored: \(items)"
  142. )
  143. }
  144. // Return the non-legacy item.
  145. for item in items {
  146. if item[kSecAttrService as String] != nil {
  147. return item[kSecValueData as String] as? Data
  148. }
  149. }
  150. // If they were all legacy items, just return the first one.
  151. // This should not happen, since only one account should be
  152. // stored.
  153. return items[0][kSecValueData as String] as? Data
  154. }
  155. if status == errSecItemNotFound {
  156. return nil
  157. } else {
  158. throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
  159. }
  160. }
  161. // TODO: This function can go away in favor of `setItem` if we can delete the legacy processing.
  162. func setItemLegacy(_ item: Data, withQuery query: [String: Any]) throws {
  163. let attributes: [String: Any] = [
  164. kSecValueData as String: item,
  165. kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
  166. ]
  167. let combined = attributes.merging(query, uniquingKeysWith: { _, last in last })
  168. var hasItem = false
  169. var status = keychainStorage.add(query: combined)
  170. if status == errSecDuplicateItem {
  171. hasItem = true
  172. status = keychainStorage.update(query: query, attributes: attributes)
  173. }
  174. if status == noErr {
  175. return
  176. }
  177. let function = hasItem ? "SecItemUpdate" : "SecItemAdd"
  178. throw AuthErrorUtils.keychainError(function: function, status: status)
  179. }
  180. /// Deletes legacy item from the keychain if it is not already known to be deleted.
  181. /// - Parameter key: The key for the item.
  182. private func deleteLegacyItem(key: String) {
  183. if legacyEntryDeletedForKey.value().contains(key) {
  184. return
  185. }
  186. let query = legacyGenericPasswordQuery(key: key)
  187. keychainStorage.delete(query: query)
  188. legacyEntryDeletedForKey.withLock { $0.insert(key) }
  189. }
  190. /// Returns a keychain query of generic password to be used to manipulate key'ed value.
  191. /// - Parameter key: The key for the value being manipulated, used as the account field in the
  192. /// query.
  193. private func genericPasswordQuery(key: String) -> [String: Any] {
  194. if key.isEmpty {
  195. fatalError("The key cannot be empty.")
  196. }
  197. var query: [String: Any] = [
  198. kSecClass as String: kSecClassGenericPassword,
  199. kSecAttrAccount as String: kAccountPrefix + key,
  200. kSecAttrService as String: service,
  201. ]
  202. query[kSecUseDataProtectionKeychain as String] = true
  203. return query
  204. }
  205. /// Returns a keychain query of generic password without service field, which is used by
  206. /// previous version of this class .
  207. /// - Parameter key: The key for the value being manipulated, used as the account field in the
  208. /// query.
  209. private func legacyGenericPasswordQuery(key: String) -> [String: Any] {
  210. [
  211. kSecClass as String: kSecClassGenericPassword,
  212. kSecAttrAccount as String: key,
  213. ]
  214. }
  215. }