Files
keychain-kit/Sources/KeychainKit/Classes/KeychainStorage.swift
2025-11-12 19:40:08 +02:00

194 lines
7.0 KiB
Swift

import Foundation
import LocalAuthentication
import Security
/// A service that provides access and management for keychain items.
///
/// This type provides direct access to the system keychain using `Security` and
/// `LocalAuthentication` frameworks. It supports querying, inserting, deleting, and checking item
/// existence, while handling authentication contexts and access controls automatically.
///
/// ## Topics
///
/// ### Initializers
///
/// - ``init(service:context:)``
///
/// ### Instance Properties
///
/// - ``service``
/// - ``context``
///
/// ### Instance Methods
///
/// - ``get(by:)->Data?``
/// - ``insert(_:by:)-(Data,_)``
/// - ``delete(by:)``
/// - ``exists(by:)``
public final class KeychainStorage<
Account: KeychainAccountProtocol,
Service: KeychainServiceProtocol
>: KeychainStorageProtocol, @unchecked Sendable {
// MARK: - Properties
/// The service descriptor associated with this keychain storage.
public let service: Service?
/// The authentication context used for keychain operations.
public let context: LAContext?
// MARK: - Initialization
/// Creates a new keychain storage instance.
///
/// - Parameters:
/// - service: The service descriptor that defines the keychain group and access settings.
/// - context: The authentication context used for secure access, or `nil` to use a default one.
public init(service: Service?, context: LAContext?) {
self.service = service
self.context = context
}
// MARK: - Methods
/// Retrieves raw data for the given account.
///
/// - Parameter account: The account descriptor identifying the stored item.
/// - Returns: The stored data, or `nil` if no item exists.
/// - Throws: ``KeychainError/invalidData`` if the retrieved value cannot be cast to `Data`.
/// - Throws: ``KeychainError/authenticationFailed`` if user authentication fails.
/// - Throws: ``KeychainError/osStatus(_:)`` for unexpected system errors.
public func get(by account: Account) throws(KeychainError) -> Data? {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account.identifier,
kSecAttrSynchronizable: account.synchronizable,
kSecUseDataProtectionKeychain: true,
kSecMatchLimit: kSecMatchLimitOne,
kSecReturnData: true
]
query[kSecAttrService] = service?.identifier
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
var result: AnyObject?
switch SecItemCopyMatching(query as CFDictionary, &result) {
case errSecSuccess:
if let data = result as? Data {
return data
} else {
throw .invalidData
}
case errSecItemNotFound:
return nil
case errSecAuthFailed, errSecInteractionNotAllowed, errSecUserCanceled:
throw .authenticationFailed
case let status:
throw .osStatus(status)
}
}
/// Inserts raw data for the given account.
///
/// - Parameters:
/// - value: The data to store.
/// - account: The account descriptor identifying the target item.
/// - Throws: ``KeychainError/underlying(_:)`` if access control creation fails.
/// - Throws: ``KeychainError/duplicateItem`` if an item with the same key already exists.
/// - Throws: ``KeychainError/osStatus(_:)`` for unexpected system errors.
public func insert(_ value: Data, by account: Account) throws(KeychainError) {
var error: Unmanaged<CFError>?
let access = SecAccessControlCreateWithFlags(
nil, account.protection, account.accessFlags, &error
)
guard let access else {
let error = error?.takeRetainedValue()
throw .underlying(error as? NSError)
}
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account.identifier,
kSecAttrSynchronizable: account.synchronizable,
kSecUseDataProtectionKeychain: true,
kSecAttrAccessControl: access,
kSecValueData: value
]
query[kSecAttrService] = service?.identifier
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
switch SecItemAdd(query as CFDictionary, nil) {
case errSecSuccess:
return
case errSecDuplicateItem:
throw .duplicateItem
case let status:
throw .osStatus(status)
}
}
/// Deletes the item for the given account.
///
/// - Parameter account: The account descriptor identifying the item to remove.
/// - Throws: ``KeychainError/authenticationFailed`` if user authentication fails.
/// - Throws: ``KeychainError/osStatus(_:)`` for unexpected system errors.
public func delete(by account: Account) throws(KeychainError) {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account.identifier,
kSecAttrSynchronizable: account.synchronizable,
kSecUseDataProtectionKeychain: true
]
query[kSecAttrService] = service?.identifier
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
switch SecItemDelete(query as CFDictionary) {
case errSecSuccess, errSecItemNotFound:
return
case errSecAuthFailed, errSecInteractionNotAllowed, errSecUserCanceled:
throw .authenticationFailed
case let status:
throw .osStatus(status)
}
}
/// Checks whether an item exists for the given account.
///
/// - Parameter account: The account descriptor identifying the stored item.
/// - Returns: `true` if the item exists; otherwise, `false`.
/// - Throws: ``KeychainError/osStatus(_:)`` for unexpected system errors.
public func exists(by account: Account) throws(KeychainError) -> Bool {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account.identifier,
kSecAttrSynchronizable: account.synchronizable,
kSecUseDataProtectionKeychain: true,
kSecMatchLimit: kSecMatchLimitOne,
kSecReturnData: false
]
let context = LAContext()
context.interactionNotAllowed = true
query[kSecAttrService] = service?.identifier
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
switch SecItemCopyMatching(query as CFDictionary, nil) {
case errSecSuccess, errSecAuthFailed, errSecInteractionNotAllowed:
return true
case errSecItemNotFound:
return false
case let status:
throw .osStatus(status)
}
}
}