diff --git a/.gitignore b/.gitignore index 70e6dad..67356a1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .build/ ## Various settings +Package.resolved *.pbxuser !default.pbxuser *.mode1v3 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/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..273919b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,16 +1,28 @@ -// swift-tools-version:5.2 +// 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: [.iOS(.v8)], - products: [ - .library(name: "KeychainKit", targets: ["KeychainKit"]), - ], - dependencies: [], - targets: [ - .target(name: "KeychainKit", dependencies: []) - ] + name: "KeychainKit", + 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", + dependencies: [ + .product(name: "Localizable", package: "localizable") + ], + resources: [ + .process("Resources/Localizable.xcstrings") + ] + ) + ] ) diff --git a/README.md b/README.md index f8af6be..c73e91a 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# 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. + +## 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., `3.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: "3.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=3.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..9788e58 --- /dev/null +++ b/Sources/KeychainKit/Classes/KeychainStorage.swift @@ -0,0 +1,193 @@ +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? + 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) + } + } +} diff --git a/Sources/KeychainKit/Enums/KeychainError.swift b/Sources/KeychainKit/Enums/KeychainError.swift new file mode 100644 index 0000000..aa48d18 --- /dev/null +++ b/Sources/KeychainKit/Enums/KeychainError.swift @@ -0,0 +1,44 @@ +import Foundation + +/// 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 was required but failed or was canceled. + case authenticationFailed + + /// 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. + /// + /// - Parameter status: The underlying `OSStatus` value. + case osStatus(OSStatus) + + /// A lower-level error occurred during encoding, decoding, or other processing. + /// + /// - 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/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..67b1da1 --- /dev/null +++ b/Sources/KeychainKit/Protocols/KeychainAccountProtocol.swift @@ -0,0 +1,53 @@ +import Foundation + +/// A type that describes a keychain account configuration for secure item storage and access. +/// +/// 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 assigned to the account. + /// + /// 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 defining additional authentication requirements. + /// + /// Defaults to an empty set (`[]`). Override this to enforce constraints like `.userPresence`, + /// `.biometryAny`, or `.devicePasscode`. + var accessFlags: SecAccessControlCreateFlags { get } + + /// Indicates whether the item is synchronized through iCloud Keychain. + /// + /// 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 { + var protection: CFString { kSecAttrAccessibleAfterFirstUnlock } + + var accessFlags: SecAccessControlCreateFlags { [] } + + var synchronizable: Bool { false } +} + +public extension KeychainAccountProtocol where Self: RawRepresentable, Self.RawValue == String { + /// A unique string that identifies the keychain account. + /// + /// 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 new file mode 100644 index 0000000..ab1af9a --- /dev/null +++ b/Sources/KeychainKit/Protocols/KeychainServiceProtocol.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A type that describes a keychain service used to group and identify stored items. +/// +/// 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 that enables shared access between apps. + /// + /// Defaults to `nil`, meaning no access group is specified. + var accessGroup: String? { get } +} + +public extension KeychainServiceProtocol { + var accessGroup: String? { nil } +} + +public extension KeychainServiceProtocol where Self: RawRepresentable, Self.RawValue == String { + /// A unique string that identifies the keychain service. + /// + /// 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 new file mode 100644 index 0000000..bf22753 --- /dev/null +++ b/Sources/KeychainKit/Protocols/KeychainStorageProtocol.swift @@ -0,0 +1,185 @@ +import Foundation + +/// A type that provides access to data stored in the keychain. +/// +/// Conforming types define how items are encoded, saved, and accessed securely, using account and +/// service descriptors to identify individual entries. +/// +/// ## Topics +/// +/// ### Associated Types +/// +/// - ``Account`` +/// - ``Service`` +/// +/// ### Instance Properties +/// +/// - ``service`` +/// +/// ### Retrieving Items +/// +/// - ``get(by:)->Data?`` +/// - ``get(by:)->String?`` +/// - ``get(by:)->UUID?`` +/// - ``get(by:decoder:)`` +/// +/// ### Inserting Items +/// +/// - ``insert(_:by:)-(Data,_)`` +/// - ``insert(_:by:)-(String,_)`` +/// - ``insert(_:by:)-(UUID,_)`` +/// - ``insert(_:by:encoder:)`` +/// +/// ### Deleting Items +/// +/// - ``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 describes a keychain service used to group stored items. + associatedtype Service: KeychainServiceProtocol + + // MARK: - Properties + + /// The keychain service associated with this storage instance. + var service: Service? { get } + + // 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`` if the operation fails. + func get(by account: Account) throws(KeychainError) -> Data? + + /// Inserts raw data for the given account. + /// + /// - Parameters: + /// - 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) + + /// Deletes the item for the given account. + /// + /// - Parameter account: The account descriptor identifying the item to remove. + /// - Throws: ``KeychainError`` if the operation fails. + func delete(by account: Account) throws(KeychainError) + + /// 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`` if the check fails. + func exists(by account: Account) throws(KeychainError) -> Bool +} + +// MARK: - Get Extension + +public extension KeychainStorageProtocol { + /// Retrieves a UTF-8 string for the given account. + /// + /// - 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 string + } + + /// Retrieves a UUID for the given account. + /// + /// - 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 uuid + } + + /// Retrieves and decodes a `Decodable` value for the given account. + /// + /// - Parameters: + /// - 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( + by account: Account, + decoder: JSONDecoder = .init() + ) throws(KeychainError) -> T? { + guard let data = try get(by: account) else { return nil } + do { + return try decoder.decode(T.self, from: data) + } catch { + throw .underlying(error as NSError) + } + } +} + +// MARK: - Set Extension + +public extension KeychainStorageProtocol { + /// Inserts a UTF-8 string for the given account. + /// + /// - Parameters: + /// - value: The string to store. + /// - 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 .invalidData + } + try insert(data, by: account) + } + + /// Inserts a UUID for the given account. + /// + /// - Parameters: + /// - value: The UUID to store. + /// - 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) + } + + /// Encodes and inserts an `Encodable` value for the given account. + /// + /// - Parameters: + /// - value: The value to encode and store. + /// - 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, + by account: Account, + encoder: JSONEncoder = .init() + ) throws(KeychainError) { + let data: Data + do { + data = try encoder.encode(value) + } catch { + 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