AuthKeychainServices.swift 9.4 KB

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