LocalData/Sources/LocalData/Services/KeychainHelper.swift

144 lines
5.0 KiB
Swift

import Foundation
import Security
/// Actor that handles all Keychain operations in isolation.
/// Provides thread-safe access to the iOS/watchOS Keychain.
actor KeychainHelper {
public static let shared = KeychainHelper()
private init() {}
// MARK: - Public Interface
/// Stores data in the keychain.
/// - Parameters:
/// - data: The data to store.
/// - service: The service identifier (usually app bundle ID or feature name).
/// - key: The account/key name.
/// - accessibility: When the keychain item should be accessible.
/// - accessControl: Optional access control (biometric, passcode, etc.).
/// - Throws: `StorageError.keychainError` if the operation fails.
public func set(
_ data: Data,
service: String,
key: String,
accessibility: KeychainAccessibility,
accessControl: KeychainAccessControl? = nil
) throws {
var attributes: [String: Any] = [
kSecValueData as String: data
]
if let accessControl {
guard let accessControlValue = accessControl.accessControl(accessibility: accessibility) else {
throw StorageError.securityApplicationFailed
}
attributes[kSecAttrAccessControl as String] = accessControlValue
} else {
attributes[kSecAttrAccessible as String] = accessibility.cfString
}
let query = baseQuery(service: service, key: key)
let addQuery = query.merging(attributes) { _, new in new }
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
// Item exists, update it
let updateStatus = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus != errSecSuccess {
throw StorageError.keychainError(updateStatus)
}
} else if status != errSecSuccess {
throw StorageError.keychainError(status)
}
}
/// Retrieves data from the keychain.
/// - Parameters:
/// - service: The service identifier.
/// - key: The account/key name.
/// - Returns: The stored data, or nil if not found.
/// - Throws: `StorageError.keychainError` if the operation fails.
public func get(service: String, key: String) throws -> Data? {
var query = baseQuery(service: service, key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
return item as? Data
} else if status == errSecItemNotFound {
return nil
} else {
throw StorageError.keychainError(status)
}
}
/// Deletes data from the keychain.
/// - Parameters:
/// - service: The service identifier.
/// - key: The account/key name.
/// - Throws: `StorageError.keychainError` if the operation fails (except for item not found).
public func delete(service: String, key: String) throws {
let query = baseQuery(service: service, key: key)
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw StorageError.keychainError(status)
}
}
/// Checks if an item exists in the keychain.
/// - Parameters:
/// - service: The service identifier.
/// - key: The account/key name.
/// - Returns: True if the item exists.
public func exists(service: String, key: String) throws -> Bool {
var query = baseQuery(service: service, key: key)
query[kSecReturnData as String] = false
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
return true
} else if status == errSecItemNotFound {
return false
} else {
throw StorageError.keychainError(status)
}
}
/// Deletes all items for a given service.
/// - Parameter service: The service identifier.
/// - Throws: `StorageError.keychainError` if the operation fails.
public func deleteAll(service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw StorageError.keychainError(status)
}
}
// MARK: - Private Helpers
private func baseQuery(service: String, key: String) -> [String: Any] {
return [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
}
}