From bb3d349993c7c1284726405570d5165232fd9892 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Mon, 7 Jul 2025 18:30:06 +0300 Subject: [PATCH] Improved type safety, local auth support, and better error handling --- KeychainKit.xcodeproj/KeychainKit_Info.plist | 25 -- KeychainKit.xcodeproj/project.pbxproj | 383 ------------------ .../contents.xcworkspacedata | 7 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - .../xcschemes/KeychainKit-Package.xcscheme | 24 -- LICENSE | 2 +- Package.swift | 19 +- README.md | 61 ++- .../KeychainKit/Classes/KeychainStorage.swift | 272 +++++++++++++ Sources/KeychainKit/Enums/KeychainError.swift | 15 + Sources/KeychainKit/Keychain.swift | 117 ------ .../Protocols/KeychainAccountProtocol.swift | 47 +++ .../Protocols/KeychainServiceProtocol.swift | 28 ++ .../Protocols/KeychainStorageProtocol.swift | 124 ++++++ 14 files changed, 556 insertions(+), 576 deletions(-) delete mode 100644 KeychainKit.xcodeproj/KeychainKit_Info.plist delete mode 100644 KeychainKit.xcodeproj/project.pbxproj delete mode 100644 KeychainKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 KeychainKit.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 KeychainKit.xcodeproj/xcshareddata/xcschemes/KeychainKit-Package.xcscheme create mode 100644 Sources/KeychainKit/Classes/KeychainStorage.swift create mode 100644 Sources/KeychainKit/Enums/KeychainError.swift delete mode 100644 Sources/KeychainKit/Keychain.swift create mode 100644 Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift create mode 100644 Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift create mode 100644 Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift diff --git a/KeychainKit.xcodeproj/KeychainKit_Info.plist b/KeychainKit.xcodeproj/KeychainKit_Info.plist deleted file mode 100644 index 57ada9f..0000000 --- a/KeychainKit.xcodeproj/KeychainKit_Info.plist +++ /dev/null @@ -1,25 +0,0 @@ - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSPrincipalClass - - - diff --git a/KeychainKit.xcodeproj/project.pbxproj b/KeychainKit.xcodeproj/project.pbxproj deleted file mode 100644 index 2b99a90..0000000 --- a/KeychainKit.xcodeproj/project.pbxproj +++ /dev/null @@ -1,383 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = "1"; - objectVersion = "46"; - objects = { - "KeychainKit::KeychainKit" = { - isa = "PBXNativeTarget"; - buildConfigurationList = "OBJ_16"; - buildPhases = ( - "OBJ_19", - "OBJ_21" - ); - dependencies = ( - ); - name = "KeychainKit"; - productName = "KeychainKit"; - productReference = "KeychainKit::KeychainKit::Product"; - productType = "com.apple.product-type.framework"; - }; - "KeychainKit::KeychainKit::Product" = { - isa = "PBXFileReference"; - path = "KeychainKit.framework"; - sourceTree = "BUILT_PRODUCTS_DIR"; - }; - "KeychainKit::SwiftPMPackageDescription" = { - isa = "PBXNativeTarget"; - buildConfigurationList = "OBJ_23"; - buildPhases = ( - "OBJ_26" - ); - dependencies = ( - ); - name = "KeychainKitPackageDescription"; - productName = "KeychainKitPackageDescription"; - productType = "com.apple.product-type.framework"; - }; - "OBJ_1" = { - isa = "PBXProject"; - attributes = { - LastSwiftMigration = "9999"; - LastUpgradeCheck = "9999"; - }; - buildConfigurationList = "OBJ_2"; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = "en"; - hasScannedForEncodings = "0"; - knownRegions = ( - "en" - ); - mainGroup = "OBJ_5"; - productRefGroup = "OBJ_11"; - projectDirPath = "."; - targets = ( - "KeychainKit::KeychainKit", - "KeychainKit::SwiftPMPackageDescription" - ); - }; - "OBJ_10" = { - isa = "PBXGroup"; - children = ( - ); - name = "Tests"; - path = ""; - sourceTree = "SOURCE_ROOT"; - }; - "OBJ_11" = { - isa = "PBXGroup"; - children = ( - "KeychainKit::KeychainKit::Product" - ); - name = "Products"; - path = ""; - sourceTree = "BUILT_PRODUCTS_DIR"; - }; - "OBJ_13" = { - isa = "PBXFileReference"; - path = "LICENSE"; - sourceTree = ""; - }; - "OBJ_14" = { - isa = "PBXFileReference"; - path = "README.md"; - sourceTree = ""; - }; - "OBJ_16" = { - isa = "XCConfigurationList"; - buildConfigurations = ( - "OBJ_17", - "OBJ_18" - ); - defaultConfigurationIsVisible = "0"; - defaultConfigurationName = "Release"; - }; - "OBJ_17" = { - isa = "XCBuildConfiguration"; - buildSettings = { - ENABLE_TESTABILITY = "YES"; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PLATFORM_DIR)/Developer/Library/Frameworks" - ); - HEADER_SEARCH_PATHS = ( - "$(inherited)" - ); - INFOPLIST_FILE = "KeychainKit.xcodeproj/KeychainKit_Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = "8.0"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "$(TOOLCHAIN_DIR)/usr/lib/swift/macosx" - ); - MACOSX_DEPLOYMENT_TARGET = "10.10"; - OTHER_CFLAGS = ( - "$(inherited)" - ); - OTHER_LDFLAGS = ( - "$(inherited)" - ); - OTHER_SWIFT_FLAGS = ( - "$(inherited)" - ); - PRODUCT_BUNDLE_IDENTIFIER = "KeychainKit"; - PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = "YES"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - "$(inherited)" - ); - SWIFT_VERSION = "5.0"; - TARGET_NAME = "KeychainKit"; - TVOS_DEPLOYMENT_TARGET = "9.0"; - WATCHOS_DEPLOYMENT_TARGET = "2.0"; - }; - name = "Debug"; - }; - "OBJ_18" = { - isa = "XCBuildConfiguration"; - buildSettings = { - ENABLE_TESTABILITY = "YES"; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PLATFORM_DIR)/Developer/Library/Frameworks" - ); - HEADER_SEARCH_PATHS = ( - "$(inherited)" - ); - INFOPLIST_FILE = "KeychainKit.xcodeproj/KeychainKit_Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = "8.0"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "$(TOOLCHAIN_DIR)/usr/lib/swift/macosx" - ); - MACOSX_DEPLOYMENT_TARGET = "10.10"; - OTHER_CFLAGS = ( - "$(inherited)" - ); - OTHER_LDFLAGS = ( - "$(inherited)" - ); - OTHER_SWIFT_FLAGS = ( - "$(inherited)" - ); - PRODUCT_BUNDLE_IDENTIFIER = "KeychainKit"; - PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = "YES"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - "$(inherited)" - ); - SWIFT_VERSION = "5.0"; - TARGET_NAME = "KeychainKit"; - TVOS_DEPLOYMENT_TARGET = "9.0"; - WATCHOS_DEPLOYMENT_TARGET = "2.0"; - }; - name = "Release"; - }; - "OBJ_19" = { - isa = "PBXSourcesBuildPhase"; - files = ( - "OBJ_20" - ); - }; - "OBJ_2" = { - isa = "XCConfigurationList"; - buildConfigurations = ( - "OBJ_3", - "OBJ_4" - ); - defaultConfigurationIsVisible = "0"; - defaultConfigurationName = "Release"; - }; - "OBJ_20" = { - isa = "PBXBuildFile"; - fileRef = "OBJ_9"; - }; - "OBJ_21" = { - isa = "PBXFrameworksBuildPhase"; - files = ( - ); - }; - "OBJ_23" = { - isa = "XCConfigurationList"; - buildConfigurations = ( - "OBJ_24", - "OBJ_25" - ); - defaultConfigurationIsVisible = "0"; - defaultConfigurationName = "Release"; - }; - "OBJ_24" = { - isa = "XCBuildConfiguration"; - buildSettings = { - LD = "/usr/bin/true"; - OTHER_SWIFT_FLAGS = ( - "-swift-version", - "5", - "-I", - "$(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2", - "-target", - "x86_64-apple-macosx10.10", - "-sdk", - "/Users/mr.noone/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk", - "-package-description-version", - "5.2.0" - ); - SWIFT_VERSION = "5.0"; - }; - name = "Debug"; - }; - "OBJ_25" = { - isa = "XCBuildConfiguration"; - buildSettings = { - LD = "/usr/bin/true"; - OTHER_SWIFT_FLAGS = ( - "-swift-version", - "5", - "-I", - "$(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2", - "-target", - "x86_64-apple-macosx10.10", - "-sdk", - "/Users/mr.noone/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk", - "-package-description-version", - "5.2.0" - ); - SWIFT_VERSION = "5.0"; - }; - name = "Release"; - }; - "OBJ_26" = { - isa = "PBXSourcesBuildPhase"; - files = ( - "OBJ_27" - ); - }; - "OBJ_27" = { - isa = "PBXBuildFile"; - fileRef = "OBJ_6"; - }; - "OBJ_3" = { - isa = "XCBuildConfiguration"; - buildSettings = { - CLANG_ENABLE_OBJC_ARC = "YES"; - COMBINE_HIDPI_IMAGES = "YES"; - COPY_PHASE_STRIP = "NO"; - DEBUG_INFORMATION_FORMAT = "dwarf"; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_NS_ASSERTIONS = "YES"; - GCC_OPTIMIZATION_LEVEL = "0"; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "SWIFT_PACKAGE=1", - "DEBUG=1" - ); - MACOSX_DEPLOYMENT_TARGET = "10.10"; - ONLY_ACTIVE_ARCH = "YES"; - OTHER_SWIFT_FLAGS = ( - "$(inherited)", - "-DXcode" - ); - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = "macosx"; - SUPPORTED_PLATFORMS = ( - "macosx", - "iphoneos", - "iphonesimulator", - "appletvos", - "appletvsimulator", - "watchos", - "watchsimulator" - ); - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - "$(inherited)", - "SWIFT_PACKAGE", - "DEBUG" - ); - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - USE_HEADERMAP = "NO"; - }; - name = "Debug"; - }; - "OBJ_4" = { - isa = "XCBuildConfiguration"; - buildSettings = { - CLANG_ENABLE_OBJC_ARC = "YES"; - COMBINE_HIDPI_IMAGES = "YES"; - COPY_PHASE_STRIP = "YES"; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - GCC_OPTIMIZATION_LEVEL = "s"; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "SWIFT_PACKAGE=1" - ); - MACOSX_DEPLOYMENT_TARGET = "10.10"; - OTHER_SWIFT_FLAGS = ( - "$(inherited)", - "-DXcode" - ); - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = "macosx"; - SUPPORTED_PLATFORMS = ( - "macosx", - "iphoneos", - "iphonesimulator", - "appletvos", - "appletvsimulator", - "watchos", - "watchsimulator" - ); - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - "$(inherited)", - "SWIFT_PACKAGE" - ); - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - USE_HEADERMAP = "NO"; - }; - name = "Release"; - }; - "OBJ_5" = { - isa = "PBXGroup"; - children = ( - "OBJ_6", - "OBJ_7", - "OBJ_10", - "OBJ_11", - "OBJ_13", - "OBJ_14" - ); - path = ""; - sourceTree = ""; - }; - "OBJ_6" = { - isa = "PBXFileReference"; - explicitFileType = "sourcecode.swift"; - path = "Package.swift"; - sourceTree = ""; - }; - "OBJ_7" = { - isa = "PBXGroup"; - children = ( - "OBJ_8" - ); - name = "Sources"; - path = ""; - sourceTree = "SOURCE_ROOT"; - }; - "OBJ_8" = { - isa = "PBXGroup"; - children = ( - "OBJ_9" - ); - name = "KeychainKit"; - path = "Sources/KeychainKit"; - sourceTree = "SOURCE_ROOT"; - }; - "OBJ_9" = { - isa = "PBXFileReference"; - path = "Keychain.swift"; - sourceTree = ""; - }; - }; - rootObject = "OBJ_1"; -} diff --git a/KeychainKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/KeychainKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index fe1aa71..0000000 --- a/KeychainKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/KeychainKit.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/KeychainKit.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index a72dc2b..0000000 --- a/KeychainKit.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded - - - \ No newline at end of file diff --git a/KeychainKit.xcodeproj/xcshareddata/xcschemes/KeychainKit-Package.xcscheme b/KeychainKit.xcodeproj/xcshareddata/xcschemes/KeychainKit-Package.xcscheme deleted file mode 100644 index 45c3ade..0000000 --- a/KeychainKit.xcodeproj/xcshareddata/xcschemes/KeychainKit-Package.xcscheme +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/LICENSE b/LICENSE index 7365fe7..b033d92 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Aleksey Zgurskiy +Copyright (c) 2025 ANGD Dev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift index e11cace..c5dc016 100644 --- a/Package.swift +++ b/Package.swift @@ -1,16 +1,15 @@ -// swift-tools-version:5.2 +// swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "KeychainKit", - platforms: [.iOS(.v8)], - products: [ - .library(name: "KeychainKit", targets: ["KeychainKit"]), - ], - dependencies: [], - targets: [ - .target(name: "KeychainKit", dependencies: []) - ] + name: "KeychainKit", + platforms: [.macOS(.v10_15), .iOS(.v13)], + products: [ + .library(name: "KeychainKit", targets: ["KeychainKit"]), + ], + targets: [ + .target(name: "KeychainKit") + ] ) diff --git a/README.md b/README.md index f8af6be..ad1492a 100644 --- a/README.md +++ b/README.md @@ -1 +1,60 @@ -# keychain-kit \ No newline at end of file +# KeychainKit + +KeychainKit is a type-safe, easy-to-use wrapper around Apple’s Keychain service that supports storing, retrieving, and deleting data with optional local authentication. + +## Overview + +This library enables working with Keychain without losing control over security settings while simplifying type-safe access to data types like `Data`, `String`, `UUID`, and any `Codable` types. + +It supports optional authentication via `LAContext`, allowing integration with Face ID, Touch ID, or device passcode. + +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). + +### Adding to an Xcode Project + +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.0.0`). +5. Add the library to your target module. + +### Adding to Package.swift + +If you are using Swift Package Manager with a `Package.swift` file, add the dependency like this: + +```swift +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "YourProject", + dependencies: [ + .package(url: "https://github.com/angd-dev/keychain-kit.git", from: "2.0.0") + ], + targets: [ + .target( + name: "YourTarget", + dependencies: [ + .product(name: "KeychainKit", package: "keychain-kit") + ] + ) + ] +) +``` + +## Additional Resources + +For more information and usage examples, see the [documentation](https://docs.angd.dev/?package=keychain-kit&version=2.0.0). + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. diff --git a/Sources/KeychainKit/Classes/KeychainStorage.swift b/Sources/KeychainKit/Classes/KeychainStorage.swift new file mode 100644 index 0000000..8529d7e --- /dev/null +++ b/Sources/KeychainKit/Classes/KeychainStorage.swift @@ -0,0 +1,272 @@ +import Foundation +import LocalAuthentication +import Security + +/// A type-safe storage abstraction over the Keychain service. +/// +/// Supports storing, retrieving, and deleting generic data associated with +/// accounts and services, with optional local authentication context support. +/// +/// ## Topics +/// +/// ### Initializers +/// +/// - ``init(service:context:)`` +/// +/// ### Instance Properties +/// +/// - ``service`` +/// - ``context`` +/// +/// ### Retrieving Values +/// +/// - ``get(_:)-5u61a`` +/// - ``get(_:)-502rt`` +/// - ``get(_:)-63a3x`` +/// - ``get(_:decoder:)`` +/// +/// ### Storing Values +/// +/// - ``set(_:for:)-7053g`` +/// - ``set(_:for:)-99s6o`` +/// - ``set(_:for:)-2e1p6`` +/// - ``set(_:for:encoder:)`` +/// +/// ### Deleting Values +/// +/// - ``delete(_:)`` +public final class KeychainStorage< + Account: KeychainAccountProtocol, + Service: KeychainServiceProtocol +>: KeychainStorageProtocol { + // MARK: - Properties + + /// The service metadata associated with this Keychain storage instance. + public let service: Service? + + /// An optional local authentication context used for biometric or passcode protection. + public let context: LAContext? + + // MARK: - Inits + + /// Creates a new `KeychainStorage` instance with the given service and authentication context. + /// + /// - Parameters: + /// - service: An optional `Service` instance representing the keychain service metadata. + /// - context: An optional `LAContext` instance for authentication protection. + public init(service: Service?, context: LAContext?) { + self.service = service + self.context = context + } + + // MARK: - Methods + + /// Retrieves raw `Data` stored in Keychain for the specified account. + /// + /// - Parameter account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Returns: The raw data associated with the given account. + /// - Throws: ``KeychainError/itemNotFound`` when no keychain item matches the query. + /// - Throws: ``KeychainError/authenticationFailed`` if biometric or device authentication fails. + /// - Throws: ``KeychainError/unexpectedData`` if the stored data is missing or corrupted. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` for any other OSStatus error returned by the Keychain API. + public func get(_ 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 + ] + + query[kSecAttrService] = service?.identifier + query[kSecAttrAccessGroup] = service?.accessGroup + query[kSecUseAuthenticationContext] = context + + var queryResult: AnyObject? + let status = withUnsafeMutablePointer(to: &queryResult) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + switch status { + case errSecSuccess: + guard + let item = queryResult as? [CFString : AnyObject], + let data = item[kSecValueData] as? Data + else { throw KeychainError.unexpectedData } + return data + case errSecItemNotFound: + throw KeychainError.itemNotFound + case errSecAuthFailed: + throw KeychainError.authenticationFailed + default: + throw KeychainError.unexpectedCode(status) + } + } + + /// Retrieves a UTF-8 encoded string stored in Keychain for the specified account. + /// + /// - Parameter account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Returns: The stored string value associated with the account. + /// - Throws: ``KeychainError/itemNotFound`` when no keychain item matches the query. + /// - Throws: ``KeychainError/authenticationFailed`` if biometric or device authentication fails. + /// - Throws: ``KeychainError/unexpectedData`` if the stored data cannot be decoded as UTF-8. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` for any other OSStatus error returned by the Keychain API. + public func get(_ account: Account) throws(KeychainError) -> String { + guard let value = String(data: try get(account), encoding: .utf8) else { + throw KeychainError.unexpectedData + } + return value + } + + /// Retrieves a `UUID` stored in Keychain for the specified account. + /// + /// - Parameter account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Returns: The stored UUID value associated with the account. + /// - Throws: ``KeychainError/itemNotFound`` when no keychain item matches the query. + /// - Throws: ``KeychainError/authenticationFailed`` if biometric or device authentication fails. + /// - Throws: ``KeychainError/unexpectedData`` if the stored string is missing or is not a valid UUID. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` for any other OSStatus error returned by the Keychain API. + public func get(_ account: Account) throws(KeychainError) -> UUID { + guard let value = UUID(uuidString: try get(account)) else { + throw KeychainError.unexpectedData + } + return value + } + + /// Retrieves a value of type `T` stored in Keychain, decoded from JSON using the provided decoder. + /// + /// - Parameters: + /// - account: The account identifier conforming to `KeychainAccountProtocol`. + /// - decoder: The `JSONDecoder` instance used to decode the data (default is a new instance). + /// - Returns: The decoded value of type `T`. + /// - Throws: ``KeychainError/itemNotFound`` when no keychain item matches the query. + /// - Throws: ``KeychainError/authenticationFailed`` if biometric or device authentication fails. + /// - Throws: ``KeychainError/unexpectedData`` if the stored data is missing or corrupted. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` for any OSStatus error returned by the Keychain API. + /// - Throws: ``KeychainError/unexpectedError(_:)`` if decoding the data into `T` fails. + public func get( + _ account: Account, + decoder: JSONDecoder = .init() + ) throws(KeychainError) -> T { + let value: Data = try get(account) + do { + return try decoder.decode(T.self, from: value) + } catch { + throw KeychainError.unexpectedError(error) + } + } + + /// Stores raw `Data` in the Keychain for the specified account, replacing any existing value. + /// + /// - Parameters: + /// - value: The raw data to store in the Keychain. + /// - account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Throws: ``KeychainError/unexpectedError(_:)`` if access control creation fails. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` if adding the item to the Keychain fails. + /// - Throws: Any error thrown by ``delete(_:)`` if the previous value cannot be removed. + public func set(_ value: Data, for account: Account) throws(KeychainError) { + try delete(account) + + var error: Unmanaged? + let access = SecAccessControlCreateWithFlags( + nil, account.protection, account.accessFlags, &error + ) + + guard let access else { + throw KeychainError.unexpectedError(error?.takeUnretainedValue()) + } + + 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 + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == noErr else { + throw KeychainError.unexpectedCode(status) + } + } + + /// Stores a UTF-8 encoded string in the Keychain for the specified account. + /// + /// - Parameters: + /// - value: The string value to store. + /// - account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Throws: ``KeychainError/unexpectedError(_:)`` if access control creation fails. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` if adding the item to the Keychain fails. + /// - Throws: Any error thrown by ``set(_:for:)-7053g`` if encoding or insertion fails. + public func set(_ value: String, for account: Account) throws(KeychainError) { + try set(value.data(using: .utf8)!, for: account) + } + + /// Stores a `UUID` value as a string in the Keychain for the specified account. + /// + /// - Parameters: + /// - value: The UUID value to store. + /// - account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Throws: ``KeychainError/unexpectedError(_:)`` if access control creation fails. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` if adding the item to the Keychain fails. + /// - Throws: Any error thrown by ``set(_:for:)-7053g`` if encoding or insertion fails. + public func set(_ value: UUID, for account: Account) throws(KeychainError) { + try set(value.uuidString, for: account) + } + + /// Stores an `Encodable` value in the Keychain as JSON-encoded data for the specified account. + /// + /// - Parameters: + /// - value: The value to encode and store. + /// - account: The account identifier conforming to `KeychainAccountProtocol`. + /// - encoder: The `JSONEncoder` to use for encoding the value (default is a new instance). + /// - Throws: ``KeychainError/unexpectedError(_:)`` if encoding fails. + /// - Throws: ``KeychainError/unexpectedError(_:)`` if access control creation fails. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` if adding the item to the Keychain fails. + /// - Throws: Any error thrown by ``set(_:for:)-7053g`` if insertion fails. + public func set( + _ value: T, + for account: Account, + encoder: JSONEncoder = .init() + ) throws(KeychainError) { + do { + let data = try encoder.encode(value) + try set(data, for: account) + } catch let error as KeychainError { + throw error + } catch { + throw KeychainError.unexpectedError(error) + } + } + + /// Deletes the item associated with the specified account from the Keychain. + /// + /// If no item exists for the given account, the method does nothing and does not throw an error. + /// + /// - Parameter account: The account identifier conforming to `KeychainAccountProtocol`. + /// - Throws: ``KeychainError/unexpectedCode(_:)`` if deletion fails with an unexpected OSStatus. + public func delete(_ 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 + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedCode(status) + } + } +} diff --git a/Sources/KeychainKit/Enums/KeychainError.swift b/Sources/KeychainKit/Enums/KeychainError.swift new file mode 100644 index 0000000..fb416fd --- /dev/null +++ b/Sources/KeychainKit/Enums/KeychainError.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Errors that can occur during Keychain operations. +public enum KeychainError: Error { + /// Authentication failed, e.g., due to biometric or passcode denial. + 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?) +} diff --git a/Sources/KeychainKit/Keychain.swift b/Sources/KeychainKit/Keychain.swift deleted file mode 100644 index 5df6fea..0000000 --- a/Sources/KeychainKit/Keychain.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// Keychain.swift -// keychain-kit -// -// Created by Aleksey Zgurskiy on 02.02.2020. -// Copyright © 2020 mr.noone. All rights reserved. -// - -import Foundation - -public protocol KeychainProtocol { - func get(_ key: String) throws -> Data - func get(_ key: String) throws -> String - func get(_ key: String) throws -> UUID - func get(_ key: String, decoder: JSONDecoder) throws -> T where T: Decodable - - func set(_ data: Data, for key: String) throws - func set(_ value: String, for key: String) throws - func set(_ uuid: UUID, for key: String) throws - func set(_ value: T, for key: String, encoder: JSONEncoder) throws where T: Encodable - - func delete(_ key: String) throws -} - -public struct Keychain: KeychainProtocol { - public enum Error: Swift.Error { - case noData - case unexpectedData - case unexpected(code: OSStatus) - } - - // MARK: - Inits - - public init() {} - - // MARK: - Public methods - - public func get(_ key: String) throws -> Data { - let query: [CFString : AnyObject] = [ - kSecClass : kSecClassGenericPassword, - kSecAttrAccount : key as AnyObject, - kSecMatchLimit : kSecMatchLimitOne, - kSecReturnAttributes : kCFBooleanTrue, - kSecReturnData : kCFBooleanTrue - ] - - var queryResult: AnyObject? - let status = withUnsafeMutablePointer(to: &queryResult) { - SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) - } - - guard status != errSecItemNotFound else { throw Error.noData } - guard status == noErr else { throw Error.unexpected(code: status) } - - guard - let item = queryResult as? [CFString : AnyObject], - let data = item[kSecValueData] as? Data - else { throw Error.noData } - - return data - } - - public func get(_ key: String) throws -> String { - guard let value = String(data: try get(key), encoding: .utf8) else { - throw Error.unexpectedData - } - return value - } - - public func get(_ key: String) throws -> UUID { - guard let value = UUID(uuidString: try get(key)) else { - throw Error.unexpectedData - } - return value - } - - public func get(_ key: String, decoder: JSONDecoder = JSONDecoder()) throws -> T where T: Decodable { - return try decoder.decode(T.self, from: get(key)) - } - - public func set(_ data: Data, for key: String) throws { - try delete(key) - - let query: [CFString : AnyObject] = [ - kSecClass : kSecClassGenericPassword, - kSecAttrAccount : key as AnyObject, - kSecValueData : data as AnyObject - ] - - let status = SecItemAdd(query as CFDictionary, nil) - guard status == noErr else { throw Error.unexpected(code: status) } - } - - public func set(_ value: String, for key: String) throws { - try set(value.data(using: .utf8)!, for: key) - } - - public func set(_ uuid: UUID, for key: String) throws { - try set(uuid.uuidString, for: key) - } - - public func set(_ value: T, for key: String, encoder: JSONEncoder = JSONEncoder()) throws where T: Encodable { - try set(encoder.encode(value), for: key) - } - - public func delete(_ key: String) throws { - let query: [CFString : AnyObject] = [ - kSecClass : kSecClassGenericPassword, - kSecAttrAccount : key as AnyObject - ] - - let status = SecItemDelete(query as CFDictionary) - guard status == noErr || status == errSecItemNotFound else { - throw Error.unexpected(code: status) - } - } -} diff --git a/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift b/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift new file mode 100644 index 0000000..0201eb4 --- /dev/null +++ b/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift @@ -0,0 +1,47 @@ +import Foundation + +/// A protocol that defines the required properties for a keychain account descriptor. +/// +/// 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. + var identifier: String { get } + + /// The keychain data protection level for the account. + /// + /// Defaults to `kSecAttrAccessibleAfterFirstUnlock`. You may override it to use other + /// accessibility levels, such as `kSecAttrAccessibleWhenUnlocked` + /// or `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly`. + var protection: CFString { get } + + /// The access control flags used to define authentication requirements. + /// + /// Defaults to `[]` (no additional access control). Can be overridden to specify + /// constraints such as `.userPresence`, `.biometryAny`, or `.devicePasscode`. + var accessFlags: SecAccessControlCreateFlags { get } + + /// Whether the item should be marked as synchronizable via iCloud Keychain. + /// + /// Defaults to `false`. Set to `true` if the item should sync across devices. + 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`. + /// + /// The `identifier` is derived from the raw string value. + var identifier: String { rawValue } +} diff --git a/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift b/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift new file mode 100644 index 0000000..ae30994 --- /dev/null +++ b/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A protocol that defines the required properties for a keychain service descriptor. +/// +/// 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. + var identifier: String { get } + + /// An optional keychain access group identifier to support shared access between apps. + /// + /// The default implementation returns `nil`, indicating 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`. + /// + /// The `identifier` is derived from the raw string value. + var identifier: String { rawValue } +} diff --git a/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift b/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift new file mode 100644 index 0000000..943ec6b --- /dev/null +++ b/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift @@ -0,0 +1,124 @@ +import Foundation + +/// A protocol that defines a type-safe interface for storing and retrieving values +/// in the system 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. +/// +/// ## Topics +/// +/// ### Associated Types +/// +/// - ``Account`` +/// - ``Service`` +/// +/// ### Instance Properties +/// +/// - ``service`` +/// +/// ### Retrieving Values +/// +/// - ``get(_:)-2gcee`` +/// - ``get(_:)-23z7h`` +/// - ``get(_:)-4xbe6`` +/// - ``get(_:decoder:)`` +/// +/// ### Storing Values +/// +/// - ``set(_:for:)-21dla`` +/// - ``set(_:for:)-6nzkf`` +/// - ``set(_:for:)-2smpc`` +/// - ``set(_:for:encoder:)`` +/// +/// ### Deleting Values +/// +/// - ``delete(_:)`` +public protocol KeychainStorageProtocol { + /// A type that describes a keychain account and its security configuration. + associatedtype Account: KeychainAccountProtocol + + /// A type that identifies a keychain service context (e.g., app or subsystem). + 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. + 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 + + /// Retrieves the value stored in the keychain for the specified account as a UTF-8 string. + /// + /// - 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 + + /// 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. + /// + /// - 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 + + /// Stores raw `Data` in the keychain for the specified 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) + + /// Stores a UTF-8 encoded `String` in the keychain for the specified 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) +}