Files
firebase-ios-sdk/FirebaseAuth/Sources/Swift/Storage/AuthKeychainServices.swift
T
Nick Cookeandgemini-code-assist[bot] dc18b7f5b9 refactor(global): cleanup redundant availability checks and update sample apps audit (#16143)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-30 18:48:30 -04:00

247 lines
8.9 KiB
Swift

// 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.
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,
]
}
}