From e0af36f9a65391cab9c3ea079297420096a02a84 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Sun, 17 Aug 2025 20:28:17 +0300 Subject: [PATCH 1/2] Version 3 --- .swift-format | 15 ++ Package.swift | 10 +- README.md | 11 +- .../KeychainKit/Classes/KeychainStorage.swift | 167 +++++++----- Sources/KeychainKit/Enums/KeychainError.swift | 65 ++--- .../KeychainKit/Extensions/String+Error.swift | 15 ++ .../Protocols/KeychainAccountProtocol.swift | 46 ++-- .../Protocols/KeychainServiceProtocol.swift | 29 +- .../Protocols/KeychainStorageProtocol.swift | 255 ++++++++---------- .../Resources/Localizable.xcstrings | 56 ++++ 10 files changed, 376 insertions(+), 293 deletions(-) create mode 100644 .swift-format create mode 100644 Sources/KeychainKit/Extensions/String+Error.swift create mode 100644 Sources/KeychainKit/Resources/Localizable.xcstrings diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..b77abe6 --- /dev/null +++ b/.swift-format @@ -0,0 +1,15 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentBlankLines": true, + "indentation": { + "spaces": 4 + }, + "lineLength": 9999, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": false, + "rules": { + "FileScopedDeclarationPrivacy": true + } +} diff --git a/Package.swift b/Package.swift index 88859d3..8a35735 100644 --- a/Package.swift +++ b/Package.swift @@ -1,18 +1,22 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "KeychainKit", - platforms: [.macOS(.v10_15), .iOS(.v13)], + defaultLocalization: "en", + platforms: [.macOS(.v12), .iOS(.v15)], products: [ .library(name: "KeychainKit", targets: ["KeychainKit"]), ], dependencies: [ + .package(url: "https://github.com/angd-dev/localizable.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ - .target(name: "KeychainKit") + .target(name: "KeychainKit", dependencies: [ + .product(name: "Localizable", package: "localizable") + ]) ] ) diff --git a/README.md b/README.md index 1888a5c..c73e91a 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,6 @@ It supports optional authentication via `LAContext`, allowing integration with F KeychainKit does not hide the complexity of Keychain operations but provides a clean API and convenient error handling via a custom `KeychainError` type. -## Requirements - -- **Swift**: 5.10+ -- **Platforms**: macOS 10.15+, iOS 13.0+ - ## Installation To add KeychainKit to your project, use Swift Package Manager (SPM). @@ -24,7 +19,7 @@ To add KeychainKit to your project, use Swift Package Manager (SPM). 1. Open your project in Xcode. 2. Navigate to the `File` menu and select `Add Package Dependencies`. 3. Enter the repository URL: `https://github.com/angd-dev/keychain-kit.git` -4. Choose the version to install (e.g., `2.1.0`). +4. Choose the version to install (e.g., `3.0.0`). 5. Add the library to your target module. ### Adding to Package.swift @@ -38,7 +33,7 @@ import PackageDescription let package = Package( name: "YourProject", dependencies: [ - .package(url: "https://github.com/angd-dev/keychain-kit.git", from: "2.1.0") + .package(url: "https://github.com/angd-dev/keychain-kit.git", from: "3.0.0") ], targets: [ .target( @@ -53,7 +48,7 @@ let package = Package( ## Additional Resources -For more information and usage examples, see the [documentation](https://docs.angd.dev/?package=keychain-kit&version=2.1.0). +For more information and usage examples, see the [documentation](https://docs.angd.dev/?package=keychain-kit&version=3.0.0). ## License diff --git a/Sources/KeychainKit/Classes/KeychainStorage.swift b/Sources/KeychainKit/Classes/KeychainStorage.swift index 687abba..9788e58 100644 --- a/Sources/KeychainKit/Classes/KeychainStorage.swift +++ b/Sources/KeychainKit/Classes/KeychainStorage.swift @@ -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? 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) } } } diff --git a/Sources/KeychainKit/Enums/KeychainError.swift b/Sources/KeychainKit/Enums/KeychainError.swift index 0753cfd..aa48d18 100644 --- a/Sources/KeychainKit/Enums/KeychainError.swift +++ b/Sources/KeychainKit/Enums/KeychainError.swift @@ -1,41 +1,44 @@ import Foundation -/// Errors that can occur during Keychain operations. +/// An error that represents a keychain operation failure. +/// +/// Each case corresponds to a specific system or data error encountered while performing keychain +/// operations. public enum KeychainError: Error, Equatable { - /// Authentication failed, e.g., due to biometric or passcode denial. + /// Authentication was required but failed or was canceled. case authenticationFailed - /// No item found matching the query. - case itemNotFound - /// Unexpected or corrupted data found in Keychain item. - case unexpectedData - /// An unexpected OSStatus error code returned by Keychain API. - case unexpectedCode(OSStatus) - /// A generic unexpected error, with optional underlying error info. - case unexpectedError(Error?) - /// Compares two `KeychainError` values for equality. + /// An item with the same key already exists in the keychain. + case duplicateItem + + /// The stored or retrieved data has an invalid format. + case invalidData + + /// An unexpected system status code was returned. /// - /// - Parameters: - /// - lhs: The first `KeychainError` to compare. - /// - rhs: The second `KeychainError` to compare. - /// - Returns: `true` if both errors are of the same case and represent the same error details. + /// - Parameter status: The underlying `OSStatus` value. + case osStatus(OSStatus) + + /// A lower-level error occurred during encoding, decoding, or other processing. /// - /// For `.unexpectedError`, the comparison is based on the underlying `NSError` identity, - /// which includes domain and error code. - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.authenticationFailed, .authenticationFailed): - true - case (.itemNotFound, .itemNotFound): - true - case (.unexpectedData, .unexpectedData): - true - case (.unexpectedCode(let lCode), .unexpectedCode(let rCode)): - lCode == rCode - case (.unexpectedError(let lErr), .unexpectedError(let rErr)): - lErr as NSError? == rErr as NSError? - default: - false + /// - Parameter error: The underlying Foundation error, if available. + case underlying(NSError?) + + /// A localized, human-readable description of the error. + public var localizedDescription: String { + switch self { + case .authenticationFailed: + return .Error.authenticationFailed + case .duplicateItem: + return .Error.duplicateItem + case .invalidData: + return .Error.invalidData + case .osStatus(let status): + let message = SecCopyErrorMessageString(status, nil) + return .Error.osStatus(message as? String ?? "") + case .underlying(let error): + let message = error?.localizedDescription + return .Error.underlying(message ?? "") } } } diff --git a/Sources/KeychainKit/Extensions/String+Error.swift b/Sources/KeychainKit/Extensions/String+Error.swift new file mode 100644 index 0000000..687d7db --- /dev/null +++ b/Sources/KeychainKit/Extensions/String+Error.swift @@ -0,0 +1,15 @@ +import Foundation +import Localizable + +extension String { + @Localizable(bundle: .module) + enum Error { + private enum Strings { + case authenticationFailed + case duplicateItem + case invalidData + case osStatus(String) + case underlying(String) + } + } +} diff --git a/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift b/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift index 0201eb4..67b1da1 100644 --- a/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift +++ b/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift @@ -1,47 +1,53 @@ import Foundation -/// A protocol that defines the required properties for a keychain account descriptor. +/// A type that describes a keychain account configuration for secure item storage and access. /// -/// Types conforming to this protocol provide metadata for configuring secure storage -/// and access behavior for keychain items. -public protocol KeychainAccountProtocol { - /// A unique string used to identify the keychain account. +/// Conforming types define metadata that determines how the keychain protects, authenticates, and +/// optionally synchronizes specific items. +/// +/// ## Topics +/// +/// ### Properties +/// +/// - ``identifier`` +/// - ``protection`` +/// - ``accessFlags`` +/// - ``synchronizable`` +public protocol KeychainAccountProtocol: Sendable { + /// A unique string that identifies the keychain account. var identifier: String { get } - /// The keychain data protection level for the account. + /// The keychain data protection level assigned to the account. /// - /// Defaults to `kSecAttrAccessibleAfterFirstUnlock`. You may override it to use other - /// accessibility levels, such as `kSecAttrAccessibleWhenUnlocked` - /// or `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly`. + /// Defaults to `kSecAttrAccessibleAfterFirstUnlock`. You can override this to use another + /// accessibility option, such as `kSecAttrAccessibleWhenUnlocked` or + /// `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly`. var protection: CFString { get } - /// The access control flags used to define authentication requirements. + /// The access control flags defining additional authentication requirements. /// - /// Defaults to `[]` (no additional access control). Can be overridden to specify - /// constraints such as `.userPresence`, `.biometryAny`, or `.devicePasscode`. + /// Defaults to an empty set (`[]`). Override this to enforce constraints like `.userPresence`, + /// `.biometryAny`, or `.devicePasscode`. var accessFlags: SecAccessControlCreateFlags { get } - /// Whether the item should be marked as synchronizable via iCloud Keychain. + /// Indicates whether the item is synchronized through iCloud Keychain. /// - /// Defaults to `false`. Set to `true` if the item should sync across devices. + /// Defaults to `false`. Set this to `true` if the item should be available across all devices + /// associated with the same iCloud account. var synchronizable: Bool { get } } public extension KeychainAccountProtocol { - /// Default value for `protection`: accessible after first unlock. var protection: CFString { kSecAttrAccessibleAfterFirstUnlock } - /// Default value for `accessFlags`: no access control constraints. var accessFlags: SecAccessControlCreateFlags { [] } - /// Default value for `synchronizable`: not synchronized across devices. var synchronizable: Bool { false } } public extension KeychainAccountProtocol where Self: RawRepresentable, Self.RawValue == String { - /// Provides a default `identifier` implementation for `RawRepresentable` types - /// whose `RawValue` is `String`. + /// A unique string that identifies the keychain account. /// - /// The `identifier` is derived from the raw string value. + /// Derived from the instance’s raw string value. var identifier: String { rawValue } } diff --git a/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift b/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift index ae30994..ab1af9a 100644 --- a/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift +++ b/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift @@ -1,28 +1,33 @@ import Foundation -/// A protocol that defines the required properties for a keychain service descriptor. +/// A type that describes a keychain service used to group and identify stored items. /// -/// Types conforming to this protocol provide an identifier used to distinguish stored items -/// and may optionally specify an access group to enable keychain sharing between apps. -public protocol KeychainServiceProtocol { - /// A unique string used to identify the keychain service. +/// Conforming types define a unique service identifier and may optionally specify an access group +/// for sharing keychain data between multiple apps or extensions. +/// +/// ## Topics +/// +/// ### Properties +/// +/// - ``identifier`` +/// - ``accessGroup`` +public protocol KeychainServiceProtocol: Sendable { + /// A unique string that identifies the keychain service. var identifier: String { get } - - /// An optional keychain access group identifier to support shared access between apps. + + /// An optional keychain access group identifier that enables shared access between apps. /// - /// The default implementation returns `nil`, indicating no access group is specified. + /// Defaults to `nil`, meaning no access group is specified. var accessGroup: String? { get } } public extension KeychainServiceProtocol { - /// The default implementation returns `nil`, indicating that no access group is specified. var accessGroup: String? { nil } } public extension KeychainServiceProtocol where Self: RawRepresentable, Self.RawValue == String { - /// Provides a default `identifier` implementation for `RawRepresentable` types - /// whose `RawValue` is `String`. + /// A unique string that identifies the keychain service. /// - /// The `identifier` is derived from the raw string value. + /// Derived from the instance’s raw string value. var identifier: String { rawValue } } diff --git a/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift b/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift index 1f7c27a..bf22753 100644 --- a/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift +++ b/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift @@ -1,13 +1,9 @@ import Foundation -/// A protocol that defines a type-safe interface for storing and retrieving values -/// in the system keychain. +/// A type that provides access to data stored in the keychain. /// -/// This protocol provides generic support for `Data`, `String`, `UUID`, and `Codable` types. -/// It allows configuring the associated account and service context for each operation. -/// -/// Types conforming to this protocol must specify concrete types for `Account` -/// and `Service`, which describe keychain item identity and service grouping. +/// Conforming types define how items are encoded, saved, and accessed securely, using account and +/// service descriptors to identify individual entries. /// /// ## Topics /// @@ -20,205 +16,170 @@ import Foundation /// /// - ``service`` /// -/// ### Retrieving Values +/// ### Retrieving Items /// -/// - ``get(_:)-2gcee`` -/// - ``get(_:)-23z7h`` -/// - ``get(_:)-4xbe6`` -/// - ``get(_:decoder:)`` +/// - ``get(by:)->Data?`` +/// - ``get(by:)->String?`` +/// - ``get(by:)->UUID?`` +/// - ``get(by:decoder:)`` /// -/// ### Storing Values +/// ### Inserting Items /// -/// - ``set(_:for:)-21dla`` -/// - ``set(_:for:)-6nzkf`` -/// - ``set(_:for:)-2smpc`` -/// - ``set(_:for:encoder:)`` +/// - ``insert(_:by:)-(Data,_)`` +/// - ``insert(_:by:)-(String,_)`` +/// - ``insert(_:by:)-(UUID,_)`` +/// - ``insert(_:by:encoder:)`` /// -/// ### Deleting Values +/// ### Deleting Items /// -/// - ``delete(_:)`` -public protocol KeychainStorageProtocol { - /// A type that describes a keychain account and its security configuration. +/// - ``delete(by:)`` +/// +/// ### Checking Existence +/// +/// - ``exists(by:)`` +public protocol KeychainStorageProtocol: Sendable { + // MARK: - Types + + /// A type that describes a keychain account used to identify stored items. associatedtype Account: KeychainAccountProtocol - /// A type that identifies a keychain service context (e.g., app or subsystem). + /// A type that describes a keychain service used to group stored items. associatedtype Service: KeychainServiceProtocol - /// The service associated with this keychain storage instance. - /// - /// This value is used as the `kSecAttrService` when interacting with the keychain. - /// If `nil`, the default service behavior is used. + // MARK: - Properties + + /// The keychain service associated with this storage instance. var service: Service? { get } - /// Retrieves the value stored in the keychain for the specified account as raw `Data`. - /// - /// - Parameter account: The keychain account whose value should be retrieved. - /// - Returns: The data associated with the given account. - /// - Throws: An error if the item is not found, access is denied, or another keychain error occurs. - func get(_ account: Account) throws(KeychainError) -> Data + // MARK: - Methods - /// Retrieves the value stored in the keychain for the specified account as a UTF-8 string. + /// Retrieves raw data for the given account. /// - /// - Parameter account: The keychain account whose value should be retrieved. - /// - Returns: A string decoded from the stored data using UTF-8 encoding. - /// - Throws: An error if the item is not found, the data is not valid UTF-8, - /// or a keychain access error occurs. - func get(_ account: Account) throws(KeychainError) -> String + /// - Parameter account: The account descriptor identifying the stored item. + /// - Returns: The stored data, or `nil` if no item exists. + /// - Throws: ``KeychainError`` if the operation fails. + func get(by account: Account) throws(KeychainError) -> Data? - /// Retrieves the value stored in the keychain for the specified account as a `UUID`. - /// - /// - Parameter account: The keychain account whose value should be retrieved. - /// - Returns: A UUID decoded from a 16-byte binary representation stored in the keychain. - /// - Throws: An error if the item is not found, the data is not exactly 16 bytes, - /// or a keychain access error occurs. - func get(_ account: Account) throws(KeychainError) -> UUID - - /// Retrieves and decodes a value of type `T` stored in the keychain for the specified account. + /// Inserts raw data for the given account. /// /// - Parameters: - /// - account: The keychain account whose value should be retrieved. - /// - decoder: The `JSONDecoder` instance used to decode the stored data. - /// - Returns: A decoded instance of type `T`. - /// - Throws: An error if the item is not found, decoding fails, or a keychain access error occurs. - func get(_ account: Account, decoder: JSONDecoder) throws(KeychainError) -> T + /// - value: The data to store. + /// - account: The account descriptor identifying the target item. + /// - Throws: ``KeychainError`` if the operation fails. + func insert(_ value: Data, by account: Account) throws(KeychainError) - /// Stores raw `Data` in the keychain for the specified account. + /// Deletes the item for the given account. /// - /// - Parameters: - /// - value: The data to store in the keychain. - /// - account: The keychain account under which the data will be saved. - /// - Throws: An error if storing the data fails. - func set(_ value: Data, for account: Account) throws(KeychainError) + /// - Parameter account: The account descriptor identifying the item to remove. + /// - Throws: ``KeychainError`` if the operation fails. + func delete(by account: Account) throws(KeychainError) - /// Stores a UTF-8 encoded `String` in the keychain for the specified account. + /// Checks whether an item exists for the given account. /// - /// - Parameters: - /// - value: The string to store in the keychain. - /// - account: The keychain account under which the string will be saved. - /// - Throws: An error if storing the string fails. - func set(_ value: String, for account: Account) throws(KeychainError) - - /// Stores a `UUID` in the keychain for the specified account. - /// - /// - Parameters: - /// - value: The UUID to store in the keychain (stored in 16-byte binary format). - /// - account: The keychain account under which the UUID will be saved. - /// - Throws: An error if storing the UUID fails. - func set(_ value: UUID, for account: Account) throws(KeychainError) - - /// Encodes and stores a value of type `T` in the keychain for the specified account. - /// - /// - Parameters: - /// - value: The value to encode and store. - /// - account: The keychain account under which the encoded data will be saved. - /// - encoder: The `JSONEncoder` used to encode the value. - /// - Throws: An error if encoding or storing the value fails. - func set(_ value: T, for account: Account, encoder: JSONEncoder) throws(KeychainError) - - /// Deletes the keychain item associated with the specified account. - /// - /// - Parameter account: The keychain account whose stored value should be deleted. - /// - Note: If the item does not exist, the method completes silently without error. - /// - Throws: An error only if the item exists but removal fails. - func delete(_ account: Account) throws(KeychainError) + /// - Parameter account: The account descriptor identifying the stored item. + /// - Returns: `true` if the item exists; otherwise, `false`. + /// - Throws: ``KeychainError`` if the check fails. + func exists(by account: Account) throws(KeychainError) -> Bool } +// MARK: - Get Extension + public extension KeychainStorageProtocol { - /// Retrieves a UTF-8 encoded string stored in the keychain for the specified account. + /// Retrieves a UTF-8 string for the given account. /// - /// - Parameter account: The account identifier used to locate the stored value. - /// - Returns: A string decoded from the keychain data using UTF-8 encoding. - /// - Throws: ``KeychainError/unexpectedData`` if the data cannot be decoded as UTF-8. - /// - Throws: Any error thrown by ``KeychainStorageProtocol/get(_:)-2gcee`` - /// if reading the raw data fails. - func get(_ account: Account) throws(KeychainError) -> String { - guard let value = String(data: try get(account), encoding: .utf8) else { - throw KeychainError.unexpectedData + /// - Parameter account: The account descriptor identifying the stored item. + /// - Returns: The decoded string, or `nil` if no item exists. + /// - Throws: ``KeychainError`` if retrieval fails. + /// - Throws: ``KeychainError/invalidData`` if the stored data cannot be decoded as UTF-8. + func get(by account: Account) throws(KeychainError) -> String? { + guard let data = try get(by: account) else { return nil } + guard let string = String(data: data, encoding: .utf8) else { + throw .invalidData } - return value + return string } - /// Retrieves a `UUID` stored in the keychain for the specified account. + /// Retrieves a UUID for the given account. /// - /// - Parameter account: The account identifier used to locate the stored value. - /// - Returns: A UUID decoded from the keychain string. - /// - Throws: ``KeychainError/unexpectedData`` if the stored string is missing or invalid. - /// - Throws: Any error thrown by ``KeychainStorageProtocol/get(_:)-23z7h`` - /// if reading the string from the keychain fails. - func get(_ account: Account) throws(KeychainError) -> UUID { - guard let value = UUID(uuidString: try get(account)) else { - throw KeychainError.unexpectedData + /// - Parameter account: The account descriptor identifying the stored item. + /// - Returns: The decoded UUID, or `nil` if no item exists. + /// - Throws: ``KeychainError`` if retrieval fails. + /// - Throws: ``KeychainError/invalidData`` if the stored value is not a valid UUID string. + func get(by account: Account) throws(KeychainError) -> UUID? { + guard let string: String = try get(by: account) else { return nil } + guard let uuid = UUID(uuidString: string) else { + throw .invalidData } - return value + return uuid } - /// Retrieves a value of type `T` stored in the keychain and decodes it from JSON using the given decoder. + /// Retrieves and decodes a `Decodable` value for the given account. /// /// - Parameters: - /// - account: The account identifier used to locate the stored value. - /// - decoder: The `JSONDecoder` to use for decoding. Defaults to a new instance. - /// - Returns: A decoded instance of type `T`. - /// - Throws: ``KeychainError/unexpectedError(_:)`` if the data cannot be decoded into the specified type. - /// - Throws: Any error thrown by ``KeychainStorageProtocol/get(_:)-2gcee`` if reading the raw data fails. + /// - account: The account descriptor identifying the stored item. + /// - decoder: The JSON decoder used to decode the stored data. + /// - Returns: The decoded value, or `nil` if no item exists. + /// - Throws: ``KeychainError`` if retrieval fails. + /// - Throws: ``KeychainError/underlying(_:)`` if JSON decoding fails. func get( - _ account: Account, + by account: Account, decoder: JSONDecoder = .init() - ) throws(KeychainError) -> T { - let value: Data = try get(account) + ) throws(KeychainError) -> T? { + guard let data = try get(by: account) else { return nil } do { - return try decoder.decode(T.self, from: value) + return try decoder.decode(T.self, from: data) } catch { - throw KeychainError.unexpectedError(error) + throw .underlying(error as NSError) } } - - /// Stores a UTF-8 encoded string in the keychain for the specified account. +} + +// MARK: - Set Extension + +public extension KeychainStorageProtocol { + /// Inserts a UTF-8 string for the given account. /// /// - Parameters: /// - value: The string to store. - /// - account: The account identifier used as the key for storing the value. - /// - Throws: ``KeychainError/unexpectedData`` if the string cannot be encoded as UTF-8. - /// - Throws: Any error thrown by ``KeychainStorageProtocol/set(_:for:)-21dla`` - /// if saving the data fails. - func set(_ value: String, for account: Account) throws(KeychainError) { + /// - account: The account descriptor identifying the target item. + /// - Throws: ``KeychainError`` if the operation fails. + /// - Throws: ``KeychainError/invalidData`` if the string cannot be encoded as UTF-8. + func insert(_ value: String, by account: Account) throws(KeychainError) { guard let data = value.data(using: .utf8) else { - throw KeychainError.unexpectedData + throw .invalidData } - try set(data, for: account) + try insert(data, by: account) } - /// Stores a `UUID` value as a UTF-8 encoded string in the keychain for the specified account. + /// Inserts a UUID for the given account. /// /// - Parameters: /// - value: The UUID to store. - /// - account: The account identifier used as the key for storing the value. - /// - Throws: Any error thrown by ``KeychainStorageProtocol/set(_:for:)-6nzkf`` - /// if saving the data fails. - func set(_ value: UUID, for account: Account) throws(KeychainError) { - try set(value.uuidString, for: account) + /// - account: The account descriptor identifying the target item. + /// - Throws: ``KeychainError`` if the operation fails. + func insert(_ value: UUID, by account: Account) throws(KeychainError) { + try insert(value.uuidString, by: account) } - /// Stores an `Encodable` value in the keychain as JSON-encoded data for the specified account. + /// Encodes and inserts an `Encodable` value for the given account. /// /// - Parameters: /// - value: The value to encode and store. - /// - account: The account identifier used as the key for storing the value. - /// - encoder: The JSON encoder to use (default is a new instance). - /// - Throws: ``KeychainError/unexpectedError(_:)`` if encoding the value fails. - /// - Throws: Any error thrown by ``KeychainStorageProtocol/set(_:for:)-21dla`` - /// if saving the data fails. - func set( + /// - account: The account descriptor identifying the target item. + /// - encoder: The JSON encoder used to encode the value. + /// - Throws: ``KeychainError`` if the operation fails. + /// - Throws: ``KeychainError/underlying(_:)`` if JSON encoding fails. + func insert( _ value: T, - for account: Account, + by account: Account, encoder: JSONEncoder = .init() ) throws(KeychainError) { + let data: Data do { - let data = try encoder.encode(value) - try set(data, for: account) - } catch let error as KeychainError { - throw error + data = try encoder.encode(value) } catch { - throw KeychainError.unexpectedError(error) + throw .underlying(error as NSError) } + try insert(data, by: account) } } diff --git a/Sources/KeychainKit/Resources/Localizable.xcstrings b/Sources/KeychainKit/Resources/Localizable.xcstrings new file mode 100644 index 0000000..44446a7 --- /dev/null +++ b/Sources/KeychainKit/Resources/Localizable.xcstrings @@ -0,0 +1,56 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Error.authenticationFailed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentication failed" + } + } + } + }, + "Error.duplicateItem" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Item already exists" + } + } + } + }, + "Error.invalidData" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stored item contains invalid or unexpected data" + } + } + } + }, + "Error.osStatus %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unexpected Keychain status: %@" + } + } + } + }, + "Error.underlying %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unexpected error while working with Keychain: %@" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file From daec0db688c82edb9c246dbb36b797da1c33a94c Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Wed, 17 Sep 2025 19:33:01 +0300 Subject: [PATCH 2/2] Fix resources --- Package.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 8a35735..273919b 100644 --- a/Package.swift +++ b/Package.swift @@ -8,15 +8,21 @@ let package = Package( defaultLocalization: "en", platforms: [.macOS(.v12), .iOS(.v15)], products: [ - .library(name: "KeychainKit", targets: ["KeychainKit"]), + .library(name: "KeychainKit", targets: ["KeychainKit"]) ], dependencies: [ .package(url: "https://github.com/angd-dev/localizable.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ - .target(name: "KeychainKit", dependencies: [ - .product(name: "Localizable", package: "localizable") - ]) + .target( + name: "KeychainKit", + dependencies: [ + .product(name: "Localizable", package: "localizable") + ], + resources: [ + .process("Resources/Localizable.xcstrings") + ] + ) ] )