Version 3

This commit is contained in:
2025-08-17 20:28:17 +03:00
parent 55ec6cedb4
commit e0af36f9a6
10 changed files with 376 additions and 293 deletions

View File

@@ -2,10 +2,11 @@ import Foundation
import LocalAuthentication
import Security
/// A type-safe storage abstraction over the Keychain service.
/// A service that provides access and management for keychain items.
///
/// Supports storing, retrieving, and deleting generic data associated with
/// accounts and services, with optional local authentication context support.
/// 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
///
@@ -18,36 +19,31 @@ import Security
/// - ``service``
/// - ``context``
///
/// ### Retrieving Values
/// ### Instance Methods
///
/// - ``get(_:)``
///
/// ### Storing Values
///
/// - ``set(_:for:)``
///
/// ### Deleting Values
///
/// - ``delete(_:)``
/// - ``get(by:)->Data?``
/// - ``insert(_:by:)-(Data,_)``
/// - ``delete(by:)``
/// - ``exists(by:)``
public final class KeychainStorage<
Account: KeychainAccountProtocol,
Service: KeychainServiceProtocol
>: KeychainStorageProtocol {
>: KeychainStorageProtocol, @unchecked Sendable {
// MARK: - Properties
/// The service metadata associated with this Keychain storage instance.
/// The service descriptor associated with this keychain storage.
public let service: Service?
/// An optional local authentication context used for biometric or passcode protection.
/// The authentication context used for keychain operations.
public let context: LAContext?
// MARK: - Inits
// MARK: - Initialization
/// Creates a new `KeychainStorage` instance with the given service and authentication context.
/// Creates a new keychain storage instance.
///
/// - Parameters:
/// - service: An optional `Service` instance representing the keychain service metadata.
/// - context: An optional `LAContext` instance for authentication protection.
/// - 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
@@ -55,23 +51,20 @@ public final class KeychainStorage<
// MARK: - Methods
/// Retrieves raw `Data` stored in the keychain for the specified account.
/// Retrieves raw data for the given account.
///
/// - Parameter account: The account identifier used to locate the stored value.
/// - Returns: The raw data associated with the specified account.
///
/// - Throws: ``KeychainError/itemNotFound`` if no matching item is found in the keychain.
/// - Throws: ``KeychainError/authenticationFailed`` if biometric or device authentication fails.
/// - Throws: ``KeychainError/unexpectedData`` if the retrieved data is missing or corrupted.
/// - Throws: ``KeychainError/unexpectedCode(_:)`` for any other unexpected OSStatus error.
public func get(_ account: Account) throws(KeychainError) -> Data {
/// - 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,
kSecReturnAttributes: true,
kSecReturnData: true
]
@@ -79,50 +72,41 @@ public final class KeychainStorage<
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
var queryResult: AnyObject?
let status = withUnsafeMutablePointer(to: &queryResult) {
SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0))
}
var result: AnyObject?
switch status {
switch SecItemCopyMatching(query as CFDictionary, &result) {
case errSecSuccess:
guard
let item = queryResult as? [CFString : AnyObject],
let data = item[kSecValueData] as? Data
else { throw KeychainError.unexpectedData }
return data
if let data = result as? Data {
return data
} else {
throw .invalidData
}
case errSecItemNotFound:
throw KeychainError.itemNotFound
case errSecAuthFailed:
throw KeychainError.authenticationFailed
default:
throw KeychainError.unexpectedCode(status)
return nil
case errSecAuthFailed, errSecInteractionNotAllowed, errSecUserCanceled:
throw .authenticationFailed
case let status:
throw .osStatus(status)
}
}
/// Stores raw `Data` in the keychain for the specified account, replacing any existing value.
///
/// This method first deletes any existing keychain item for the account, then creates a new
/// item with the specified data and applies the access control settings from the account's
/// protection and flags.
/// Inserts raw data for the given account.
///
/// - Parameters:
/// - value: The raw data to store.
/// - account: The account identifier conforming to `KeychainAccountProtocol`.
///
/// - Throws: ``KeychainError/unexpectedError(_:)`` if access control creation fails.
/// - Throws: ``KeychainError/unexpectedCode(_:)`` if adding the new item to the keychain fails.
/// - Throws: Any error thrown by ``delete(_:)`` if the existing item cannot be removed.
public func set(_ value: Data, for account: Account) throws(KeychainError) {
try delete(account)
/// - 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 {
throw KeychainError.unexpectedError(error?.takeUnretainedValue())
let error = error?.takeRetainedValue()
throw .underlying(error as? NSError)
}
var query: [CFString: Any] = [
@@ -138,19 +122,22 @@ public final class KeychainStorage<
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
let status = SecItemAdd(query as CFDictionary, nil)
guard status == noErr else {
throw KeychainError.unexpectedCode(status)
switch SecItemAdd(query as CFDictionary, nil) {
case errSecSuccess:
return
case errSecDuplicateItem:
throw .duplicateItem
case let status:
throw .osStatus(status)
}
}
/// Deletes the keychain item associated with the specified account.
/// Deletes the item for the given account.
///
/// If no item exists for the given account, this method completes silently without error.
///
/// - Parameter account: The account identifier conforming to `KeychainAccountProtocol`.
/// - Throws: ``KeychainError/unexpectedCode(_:)`` if the deletion fails with an unexpected OSStatus.
public func delete(_ account: Account) throws(KeychainError) {
/// - 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,
@@ -162,9 +149,45 @@ public final class KeychainStorage<
query[kSecAttrAccessGroup] = service?.accessGroup
query[kSecUseAuthenticationContext] = context
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedCode(status)
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)
}
}
}