From 9ec5bdcb0eedcfe1a08fa18f5205741dcf80a16d Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 11 Jan 2025 19:13:48 +0000 Subject: [PATCH 1/7] Keychain WIP --- Package.swift | 5 + Sources/Keychain/Keychain.swift | 132 +++++++++++++++++++++++++++ Sources/Keychain/KeychainError.swift | 18 ++++ Sources/Keychain/String+Data.swift | 54 +++++++++++ 4 files changed, 209 insertions(+) create mode 100644 Sources/Keychain/Keychain.swift create mode 100644 Sources/Keychain/KeychainError.swift create mode 100644 Sources/Keychain/String+Data.swift diff --git a/Package.swift b/Package.swift index 4c2ab9b..3ff0d5a 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,10 @@ let package = Package( name: "ColorUtilities", targets: ["ColorUtilities"] ), + .library( + name: "Keychain", + targets: ["Keychain"] + ), .library( name: "Utilities", targets: ["Utilities"] @@ -25,6 +29,7 @@ let package = Package( ], targets: [ .target(name: "ColorUtilities"), + .target(name: "Keychain"), .target(name: "Utilities"), .target(name: "ViewRenderer"), .testTarget( diff --git a/Sources/Keychain/Keychain.swift b/Sources/Keychain/Keychain.swift new file mode 100644 index 0000000..599ede3 --- /dev/null +++ b/Sources/Keychain/Keychain.swift @@ -0,0 +1,132 @@ +// +// KeychainToken.swift +// Keychain +// +// Created by Ben Shutt on 11/01/2025. +// + +import Foundation + +/// Store a token (e.g. authentication) into the Keychain. +/// +/// - Warning: +/// Consider keychain thread blocking when using this structure. +/// E.g. Use of `applicationProtectedDataDidBecomeAvailable`. +/// https://stackoverflow.com/a/61313746. +/// +/// # Concurrency +/// https://developer.apple.com/documentation/security/working-with-concurrency +actor Keychain { + + /// `String.Encoding` converting from `Data` to `String` and vice versa + public let encoding: String.Encoding = .utf8 + + /// Value for key `kSecClass` in Keychain query + public let classKey: CFString = kSecClassKey + + /// Value for key `kSecAttrApplicationTag` in Keychain query + public let keychainIdentifier: String + + /// `Data` of `keychainIdentifier` using `encoding` + private func keychainIdentifierData() throws -> Data { + try keychainIdentifier.encode(with: encoding) + } + + // MARK: - Init + + /// Initialize with `keychainIdentifier` + /// + /// - Parameter keychainIdentifier: `String` + public init(keychainIdentifier: String) { + self.keychainIdentifier = keychainIdentifier + } + + // MARK: - Read + + /// Read token from Keychain + public func readToken() throws -> String { + let fetchQuery: [String: Any] = [ + kSecClass as String: classKey, + kSecAttrApplicationTag as String: try keychainIdentifierData(), + kSecReturnData as String: true + ] + + var item: AnyObject? + let status = SecItemCopyMatching( + fetchQuery as CFDictionary, + &item + ) + + guard status == errSecSuccess else { + throw KeychainError.invalidStatus(status) + } + + guard let data = item as? Data else { + throw KeychainError.invalidReference + } + + return try data.decodeString(with: encoding) + } + + // MARK: - Write/Update + + /// Try `writeToken(_:)` falling back on `updateToken(_:)` + /// + /// - Parameter token: `String` token + public func writeOrUpdate(_ token: String) throws { + do { + try writeToken(token) + } catch { + try updateToken(token) + } + } + + /// Write token to Keychain + /// - Parameter token: `String` token + private func writeToken(_ token: String) throws { + let tokenData = try token.encode(with: encoding) + let addQuery: [String: Any] = [ + kSecClass as String: classKey, + kSecAttrApplicationTag as String: try keychainIdentifierData(), + kSecValueData as String: tokenData + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.invalidStatus(status) + } + } + + /// Update token in Keychain + /// - Parameter token: `String` token + private func updateToken(_ token: String) throws { + let tokenData = try token.encode(with: encoding) + let updateQuery: [String: Any] = [ + kSecClass as String: classKey, + kSecAttrApplicationTag as String: try keychainIdentifierData() + ] + let attributes = [ + kSecValueData as String: tokenData + ] + let status = SecItemUpdate( + updateQuery as CFDictionary, + attributes as CFDictionary + ) + guard status == errSecSuccess else { + throw KeychainError.invalidStatus(status) + } + } + + // MARK: - Delete + + /// Delete token from Keychain + public func deleteToken() throws { + let deleteQuery: [String: Any] = [ + kSecClass as String: classKey, + kSecAttrApplicationTag as String: try keychainIdentifierData() + ] + let status = SecItemDelete(deleteQuery as CFDictionary) + guard status == errSecSuccess else { + throw KeychainError.invalidStatus(status) + } + } +} diff --git a/Sources/Keychain/KeychainError.swift b/Sources/Keychain/KeychainError.swift new file mode 100644 index 0000000..1a186ab --- /dev/null +++ b/Sources/Keychain/KeychainError.swift @@ -0,0 +1,18 @@ +// +// KeychainError.swift +// Keychain +// +// Created by Ben Shutt on 11/01/2025. +// + +import Foundation + +/// `Error`s thrown in Keychain operations +public enum KeychainError: Error { + + /// The given `status` was not valid + case invalidStatus(_ status: OSStatus) + + /// The `ref` returned from the Keychain was invalid + case invalidReference +} diff --git a/Sources/Keychain/String+Data.swift b/Sources/Keychain/String+Data.swift new file mode 100644 index 0000000..9b7366c --- /dev/null +++ b/Sources/Keychain/String+Data.swift @@ -0,0 +1,54 @@ +// +// String+Data.swift +// Keychain +// +// Created by Ben Shutt on 11/01/2025. +// + +import Foundation + +/// An `Error` converting from `String` to `Data` or vice versa +public enum StringDataError: Error { + + /// Failed to create a `String` from the given `Data` and `String.Encoding` + case data(Data, encoding: String.Encoding) + + /// Failed to create `Data` from the given `String` and `String.Encoding` + case string(String, encoding: String.Encoding) +} + +// MARK: - String + Data + +public extension String { + + /// `String` instance to `Data` or throw + /// + /// - Note: + /// Alternative is to use, say, `Data(self.utf8))` + /// + /// - Parameter encoding: `String.Encoding` + func encode(with encoding: String.Encoding) throws -> Data { + guard let data = data(using: encoding) else { + throw StringDataError.string(self, encoding: encoding) + } + return data + } +} + +// MARK: - Data + String + +public extension Data { + + /// `Data` instance to `String` or throw + /// + /// - Note: + /// Alternative is to use, say, `String(decoding: self, as: UTF8.self)` + /// + /// - Parameter encoding: `String.Encoding` + func decodeString(with encoding: String.Encoding) throws -> String { + guard let string = String(data: self, encoding: encoding) else { + throw StringDataError.data(self, encoding: encoding) + } + return string + } +} From c108bacde9bc300ac99331acd76c57e84c07c284 Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 1 Feb 2025 20:33:25 +0000 Subject: [PATCH 2/7] Implement Keychain library and write unit tests --- Package.swift | 41 +++-- Sources/Keychain/Keychain.swift | 132 ---------------- Sources/Keychain/KeychainActor.swift | 13 ++ Sources/Keychain/KeychainError.swift | 20 ++- .../Keychain/KeychainItem/KeychainModel.swift | 25 +++ .../Keychain/KeychainItem/SecureData.swift | 90 +++++++++++ Sources/Keychain/KeychainManager.swift | 114 ++++++++++++++ Sources/Keychain/Query/KeychainClass.swift | 18 +++ .../Keychain/Query/KeychainMatchLimit.swift | 18 +++ Sources/Keychain/Query/KeychainQuery.swift | 59 +++++++ .../Query/Protocol/KeychainConstant.swift | 18 +++ Sources/Keychain/README.md | 32 ++++ Sources/Keychain/String+Data.swift | 54 ------- .../KeychainTests/KeychainManagerTests.swift | 147 ++++++++++++++++++ 14 files changed, 577 insertions(+), 204 deletions(-) delete mode 100644 Sources/Keychain/Keychain.swift create mode 100644 Sources/Keychain/KeychainActor.swift create mode 100644 Sources/Keychain/KeychainItem/KeychainModel.swift create mode 100644 Sources/Keychain/KeychainItem/SecureData.swift create mode 100644 Sources/Keychain/KeychainManager.swift create mode 100644 Sources/Keychain/Query/KeychainClass.swift create mode 100644 Sources/Keychain/Query/KeychainMatchLimit.swift create mode 100644 Sources/Keychain/Query/KeychainQuery.swift create mode 100644 Sources/Keychain/Query/Protocol/KeychainConstant.swift create mode 100644 Sources/Keychain/README.md delete mode 100644 Sources/Keychain/String+Data.swift create mode 100644 Tests/KeychainTests/KeychainManagerTests.swift diff --git a/Package.swift b/Package.swift index 3ff0d5a..205c599 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,11 @@ import PackageDescription +let colorUtilities = "ColorUtilities" +let keychain = "Keychain" +let utilities = "Utilities" +let viewRenderer = "ViewRenderer" + let package = Package( name: "Utilities", platforms: [ @@ -11,34 +16,38 @@ let package = Package( ], products: [ .library( - name: "ColorUtilities", - targets: ["ColorUtilities"] + name: colorUtilities, + targets: [colorUtilities] ), .library( - name: "Keychain", - targets: ["Keychain"] + name: keychain, + targets: [keychain] ), .library( - name: "Utilities", - targets: ["Utilities"] + name: utilities, + targets: [utilities] ), .library( - name: "ViewRenderer", - targets: ["ViewRenderer"] + name: viewRenderer, + targets: [viewRenderer] ) ], targets: [ - .target(name: "ColorUtilities"), - .target(name: "Keychain"), - .target(name: "Utilities"), - .target(name: "ViewRenderer"), + .target(name: colorUtilities, exclude: ["README.md"]), + .target(name: keychain, exclude: ["README.md"]), + .target(name: utilities, exclude: ["README.md"]), + .target(name: viewRenderer, exclude: ["README.md"]), + .testTarget( + name: "\(colorUtilities)Tests", + dependencies: [.byName(name: colorUtilities)] + ), .testTarget( - name: "ColorUtilitiesTests", - dependencies: ["ColorUtilities"] + name: "\(keychain)Tests", + dependencies: [.byName(name: keychain)] ), .testTarget( - name: "UtilitiesTests", - dependencies: ["Utilities"] + name: "\(utilities)Tests", + dependencies: [.byName(name: utilities)] ) ] ) diff --git a/Sources/Keychain/Keychain.swift b/Sources/Keychain/Keychain.swift deleted file mode 100644 index 599ede3..0000000 --- a/Sources/Keychain/Keychain.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// KeychainToken.swift -// Keychain -// -// Created by Ben Shutt on 11/01/2025. -// - -import Foundation - -/// Store a token (e.g. authentication) into the Keychain. -/// -/// - Warning: -/// Consider keychain thread blocking when using this structure. -/// E.g. Use of `applicationProtectedDataDidBecomeAvailable`. -/// https://stackoverflow.com/a/61313746. -/// -/// # Concurrency -/// https://developer.apple.com/documentation/security/working-with-concurrency -actor Keychain { - - /// `String.Encoding` converting from `Data` to `String` and vice versa - public let encoding: String.Encoding = .utf8 - - /// Value for key `kSecClass` in Keychain query - public let classKey: CFString = kSecClassKey - - /// Value for key `kSecAttrApplicationTag` in Keychain query - public let keychainIdentifier: String - - /// `Data` of `keychainIdentifier` using `encoding` - private func keychainIdentifierData() throws -> Data { - try keychainIdentifier.encode(with: encoding) - } - - // MARK: - Init - - /// Initialize with `keychainIdentifier` - /// - /// - Parameter keychainIdentifier: `String` - public init(keychainIdentifier: String) { - self.keychainIdentifier = keychainIdentifier - } - - // MARK: - Read - - /// Read token from Keychain - public func readToken() throws -> String { - let fetchQuery: [String: Any] = [ - kSecClass as String: classKey, - kSecAttrApplicationTag as String: try keychainIdentifierData(), - kSecReturnData as String: true - ] - - var item: AnyObject? - let status = SecItemCopyMatching( - fetchQuery as CFDictionary, - &item - ) - - guard status == errSecSuccess else { - throw KeychainError.invalidStatus(status) - } - - guard let data = item as? Data else { - throw KeychainError.invalidReference - } - - return try data.decodeString(with: encoding) - } - - // MARK: - Write/Update - - /// Try `writeToken(_:)` falling back on `updateToken(_:)` - /// - /// - Parameter token: `String` token - public func writeOrUpdate(_ token: String) throws { - do { - try writeToken(token) - } catch { - try updateToken(token) - } - } - - /// Write token to Keychain - /// - Parameter token: `String` token - private func writeToken(_ token: String) throws { - let tokenData = try token.encode(with: encoding) - let addQuery: [String: Any] = [ - kSecClass as String: classKey, - kSecAttrApplicationTag as String: try keychainIdentifierData(), - kSecValueData as String: tokenData - ] - let status = SecItemAdd(addQuery as CFDictionary, nil) - guard status == errSecSuccess else { - throw KeychainError.invalidStatus(status) - } - } - - /// Update token in Keychain - /// - Parameter token: `String` token - private func updateToken(_ token: String) throws { - let tokenData = try token.encode(with: encoding) - let updateQuery: [String: Any] = [ - kSecClass as String: classKey, - kSecAttrApplicationTag as String: try keychainIdentifierData() - ] - let attributes = [ - kSecValueData as String: tokenData - ] - let status = SecItemUpdate( - updateQuery as CFDictionary, - attributes as CFDictionary - ) - guard status == errSecSuccess else { - throw KeychainError.invalidStatus(status) - } - } - - // MARK: - Delete - - /// Delete token from Keychain - public func deleteToken() throws { - let deleteQuery: [String: Any] = [ - kSecClass as String: classKey, - kSecAttrApplicationTag as String: try keychainIdentifierData() - ] - let status = SecItemDelete(deleteQuery as CFDictionary) - guard status == errSecSuccess else { - throw KeychainError.invalidStatus(status) - } - } -} diff --git a/Sources/Keychain/KeychainActor.swift b/Sources/Keychain/KeychainActor.swift new file mode 100644 index 0000000..5051904 --- /dev/null +++ b/Sources/Keychain/KeychainActor.swift @@ -0,0 +1,13 @@ +// +// KeychainActor.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +/// Global actor to isolate keychain calls. +/// https://developer.apple.com/documentation/security/working-with-concurrency +@globalActor +public actor KeychainActor { + public static let shared = KeychainActor() +} diff --git a/Sources/Keychain/KeychainError.swift b/Sources/Keychain/KeychainError.swift index 1a186ab..736f8a0 100644 --- a/Sources/Keychain/KeychainError.swift +++ b/Sources/Keychain/KeychainError.swift @@ -10,9 +10,25 @@ import Foundation /// `Error`s thrown in Keychain operations public enum KeychainError: Error { - /// The given `status` was not valid + /// The given status was not valid case invalidStatus(_ status: OSStatus) - /// The `ref` returned from the Keychain was invalid + /// The reference returned from the Keychain was invalid case invalidReference + + /// Get the status when the error is type `invalidStatus` + public var status: OSStatus? { + guard case .invalidStatus(let status) = self else { return nil } + return status + } +} + +// MARK: - OSStatus + Extensions + +public extension OSStatus { + + /// A human readable message for the status. + var message: String { + (SecCopyErrorMessageString(self, nil) as String?) ?? String(self) + } } diff --git a/Sources/Keychain/KeychainItem/KeychainModel.swift b/Sources/Keychain/KeychainItem/KeychainModel.swift new file mode 100644 index 0000000..d257017 --- /dev/null +++ b/Sources/Keychain/KeychainItem/KeychainModel.swift @@ -0,0 +1,25 @@ +// +// KeychainItem.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +import Foundation +import Security + +public protocol KeychainModel { + associatedtype PrimaryKeys: KeychainPrimaryKeysModel + associatedtype ReferenceType + + var addQuery: KeychainQuery { get } + var updateQuery: KeychainQuery { get } + var updateAttributes: KeychainQuery { get } + + init(ref: ReferenceType, keys: PrimaryKeys) +} + +public protocol KeychainPrimaryKeysModel { + var fetchQuery: KeychainQuery { get } + var deleteQuery: KeychainQuery { get } +} diff --git a/Sources/Keychain/KeychainItem/SecureData.swift b/Sources/Keychain/KeychainItem/SecureData.swift new file mode 100644 index 0000000..1b6d8fb --- /dev/null +++ b/Sources/Keychain/KeychainItem/SecureData.swift @@ -0,0 +1,90 @@ +// +// SecureData.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +import Foundation + +typealias Model = Sendable & Equatable & Hashable & Codable + +public struct SecureData: Model, KeychainModel { + public struct PrimaryKeys: Model, KeychainPrimaryKeysModel { + public var service: String + public var account: String + + public init( + service: String, + account: String + ) { + self.service = service + self.account = account + } + + public var fetchQuery: KeychainQuery { + KeychainQuery() + .class(.genericPassword) + .attrService(service) + .attrAccount(account) + .matchLimit(.one) + .returnData(true) + } + + public var deleteQuery: KeychainQuery { + KeychainQuery() + .class(.genericPassword) + .attrService(service) + .attrAccount(account) + } + } + + public var service: String + public var account: String + public var data: Data + + public var keys: PrimaryKeys { + .init( + service: service, + account: account + ) + } + + public init( + service: String, + account: String, + data: Data + ) { + self.service = service + self.account = account + self.data = data + } + + public init(ref: Data, keys: PrimaryKeys) { + self.service = keys.service + self.account = keys.account + self.data = ref + } + + // MARK: - KeychainItem + + public var addQuery: KeychainQuery { + KeychainQuery() + .class(.genericPassword) + .attrService(service) + .attrAccount(account) + .valueData(data) + } + + public var updateQuery: KeychainQuery { + KeychainQuery() + .class(.genericPassword) + .attrService(service) + .attrAccount(account) + } + + public var updateAttributes: KeychainQuery { + KeychainQuery() + .valueData(data) + } +} diff --git a/Sources/Keychain/KeychainManager.swift b/Sources/Keychain/KeychainManager.swift new file mode 100644 index 0000000..42155c3 --- /dev/null +++ b/Sources/Keychain/KeychainManager.swift @@ -0,0 +1,114 @@ +// +// KeychainManager.swift +// Keychain +// +// Created by Ben Shutt on 11/01/2025. +// + +import Foundation + +/// Wrapper for storing secure data into the Keychain. +/// E.g. an authentication token. +@KeychainActor +public enum KeychainManager { + + // MARK: - KeychainModel + + public static func fetch( + _ keys: T.PrimaryKeys + ) throws(KeychainError) -> T { + try T(ref: fetch(query: keys.fetchQuery), keys: keys) + } + + public static func add( + _ model: T + ) throws(KeychainError) { + try add(query: model.addQuery) + } + + public static func update( + _ model: T, + addIfNotFound: Bool = true + ) throws(KeychainError) { + do { + try update( + query: model.updateQuery, + attributes: model.updateAttributes + ) + } catch { + if addIfNotFound && error.status == errSecItemNotFound { + try add(model) + } + } + } + + public static func delete( + _ keys: T, + throwIfNotFound: Bool = false + ) throws(KeychainError) { + try delete( + query: keys.deleteQuery, + throwIfNotFound: throwIfNotFound + ) + } + + // MARK: - Query + + public static func fetch( + query: KeychainQuery + ) throws(KeychainError) -> T { + var item: CFTypeRef? + try check(status: SecItemCopyMatching( + query.cfQuery, + &item + )) + guard let value = item as? T else { + throw KeychainError.invalidReference + } + return value + } + + public static func add( + query: KeychainQuery + ) throws(KeychainError) { + try check(status: SecItemAdd( + query.cfQuery, + nil + )) + } + + public static func update( + query: KeychainQuery, + attributes: KeychainQuery + ) throws(KeychainError) { + try check(status: SecItemUpdate( + query.cfQuery, + attributes.cfQuery + )) + } + + public static func delete( + query: KeychainQuery, + throwIfNotFound: Bool = false + ) throws(KeychainError) { + do { + try check(status: SecItemDelete( + query.cfQuery + )) + } catch { + if throwIfNotFound || error.status != errSecItemNotFound { + throw error + } + } + } + + // MARK: - Helper + + private static func check( + status: OSStatus + ) throws(KeychainError) { + guard status == errSecSuccess else { + throw KeychainError.invalidStatus(status) + } + } +} diff --git a/Sources/Keychain/Query/KeychainClass.swift b/Sources/Keychain/Query/KeychainClass.swift new file mode 100644 index 0000000..af56a57 --- /dev/null +++ b/Sources/Keychain/Query/KeychainClass.swift @@ -0,0 +1,18 @@ +// +// KeychainClass.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +import Security + +public enum KeychainClass: KeychainConstant { + case genericPassword // Primary keys: kSecAttrAccount and kSecAttrService. + + public var cfString: CFString { + switch self { + case .genericPassword: kSecClassGenericPassword + } + } +} diff --git a/Sources/Keychain/Query/KeychainMatchLimit.swift b/Sources/Keychain/Query/KeychainMatchLimit.swift new file mode 100644 index 0000000..df6af2d --- /dev/null +++ b/Sources/Keychain/Query/KeychainMatchLimit.swift @@ -0,0 +1,18 @@ +// +// KeychainMatchLimit.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +import Security + +public enum KeychainMatchLimit: KeychainConstant { + case one + + public var cfString: CFString { + switch self { + case .one: kSecMatchLimitOne + } + } +} diff --git a/Sources/Keychain/Query/KeychainQuery.swift b/Sources/Keychain/Query/KeychainQuery.swift new file mode 100644 index 0000000..b155f42 --- /dev/null +++ b/Sources/Keychain/Query/KeychainQuery.swift @@ -0,0 +1,59 @@ +// +// KeychainQuery.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +import Foundation +import Security + +public struct KeychainQuery { + public let query: [String: Any] + + var cfQuery: CFDictionary { + query as CFDictionary + } + + public init() { + self.init(query: [:]) + } + + private init(query: [String: Any]) { + self.query = query + } +} + +// MARK: - Builder + +public extension KeychainQuery { + func `class`(_ value: KeychainClass) -> Self { + set(key: kSecClass, value: value.value) + } + + func attrAccount(_ value: String) -> Self { + set(key: kSecAttrAccount, value: value) + } + + func attrService(_ value: String) -> Self { + set(key: kSecAttrService, value: value) + } + + func matchLimit(_ value: KeychainMatchLimit) -> Self { + set(key: kSecMatchLimit, value: value.value) + } + + func returnData(_ value: Bool) -> Self { + set(key: kSecReturnData, value: value) + } + + func valueData(_ value: Data) -> Self { + set(key: kSecValueData, value: value) + } + + func set(key: CFString, value: Any) -> Self { + var newQuery = query + newQuery[key as String] = value + return .init(query: newQuery) + } +} diff --git a/Sources/Keychain/Query/Protocol/KeychainConstant.swift b/Sources/Keychain/Query/Protocol/KeychainConstant.swift new file mode 100644 index 0000000..badcff4 --- /dev/null +++ b/Sources/Keychain/Query/Protocol/KeychainConstant.swift @@ -0,0 +1,18 @@ +// +// KeychainConstant.swift +// Utilities +// +// Created by Ben Shutt on 12/01/2025. +// + +import Security + +public protocol KeychainConstant { + var cfString: CFString { get } +} + +public extension KeychainConstant { + var value: String { + cfString as String + } +} diff --git a/Sources/Keychain/README.md b/Sources/Keychain/README.md new file mode 100644 index 0000000..2b18ee0 --- /dev/null +++ b/Sources/Keychain/README.md @@ -0,0 +1,32 @@ +# Keychain + +Fetch, add, update, and delete secure data from the [keychain](https://developer.apple.com/documentation/security/keychain-services?language=objc). + +[kSecClassGenericPassword](https://developer.apple.com/documentation/security/ksecclassgenericpassword) + +This package provides an implementation for common queries for `SecureData`: + +```swift +struct SecureData: Sendable, Equatable, Hashable, Codable { + var service: String + var account: String + var data: Data +``` + +For example: + +```swift +try KeychainManager.add(secureData) +let secureData: SecureData = try KeychainManager.fetch(keys) +try KeychainManager.delete(keys, throwIfNotFound: false) +try KeychainManager.update(secureData, addIfNotFound: true) +``` + +## Note + +Be conscious not to access the keychain before it is available. See [SO](https://stackoverflow.com/a/61313746). + +## References + +- [Storing CryptoKit Keys in the Keychain](https://developer.apple.com/documentation/cryptokit/storing_cryptokit_keys_in_the_keychain) +- [SO](https://stackoverflow.com/a/68232091) diff --git a/Sources/Keychain/String+Data.swift b/Sources/Keychain/String+Data.swift deleted file mode 100644 index 9b7366c..0000000 --- a/Sources/Keychain/String+Data.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// String+Data.swift -// Keychain -// -// Created by Ben Shutt on 11/01/2025. -// - -import Foundation - -/// An `Error` converting from `String` to `Data` or vice versa -public enum StringDataError: Error { - - /// Failed to create a `String` from the given `Data` and `String.Encoding` - case data(Data, encoding: String.Encoding) - - /// Failed to create `Data` from the given `String` and `String.Encoding` - case string(String, encoding: String.Encoding) -} - -// MARK: - String + Data - -public extension String { - - /// `String` instance to `Data` or throw - /// - /// - Note: - /// Alternative is to use, say, `Data(self.utf8))` - /// - /// - Parameter encoding: `String.Encoding` - func encode(with encoding: String.Encoding) throws -> Data { - guard let data = data(using: encoding) else { - throw StringDataError.string(self, encoding: encoding) - } - return data - } -} - -// MARK: - Data + String - -public extension Data { - - /// `Data` instance to `String` or throw - /// - /// - Note: - /// Alternative is to use, say, `String(decoding: self, as: UTF8.self)` - /// - /// - Parameter encoding: `String.Encoding` - func decodeString(with encoding: String.Encoding) throws -> String { - guard let string = String(data: self, encoding: encoding) else { - throw StringDataError.data(self, encoding: encoding) - } - return string - } -} diff --git a/Tests/KeychainTests/KeychainManagerTests.swift b/Tests/KeychainTests/KeychainManagerTests.swift new file mode 100644 index 0000000..812acec --- /dev/null +++ b/Tests/KeychainTests/KeychainManagerTests.swift @@ -0,0 +1,147 @@ +// +// KeychainTests.swift +// Utilities +// +// Created by Ben Shutt on 13/01/2025. +// + +import Foundation +import Testing +@testable import Keychain + +@KeychainActor +@Suite( + "Unit tests for KeychainManager", + .serialized +) +struct KeychainManagerTests { + fileprivate let testToken = SecureData( + service: "com.keychain.unit.tests", + account: "test.token", + data: Data("token".utf8) + ) + + @Test func fetch() throws { + try runTest { + let token = try fetchTestToken() + #expect(token == testToken) + } + } + + @Test func add() throws { + try runTest {} + } + + @Test func updateWhenExists() throws { + try runTest { + let newToken = try updateTestToken("updateWhenExistsToken") + let token = try fetchTestToken() + #expect(token == newToken) + } + } + + @Test func updateWhenNotExists() throws { + try runTest(addBefore: false) { + let newToken = try updateTestToken("updateWhenNotExistsToken") + let token = try fetchTestToken() + #expect(token == newToken) + } + } + + @Test func deleteWhenExists() throws { + try runTest { + try deleteTestToken() + } + } + + @Test func deleteWhenNotExists() throws { + try deleteTestToken(throwIfNotFound: false) + #expect(performing: { + _ = try deleteTestToken(throwIfNotFound: true) + }, throws: { error in + try expectNotFoundError(error) + }) + } + + @Test func deleteAndFetch() throws { + try runTest { + try deleteTestToken() + #expect(performing: { + _ = try fetchTestToken() + }, throws: { error in + try expectNotFoundError(error) + }) + } + } + + @Test func all() throws { + try deleteTestToken(throwIfNotFound: false) + #expect(performing: { + _ = try fetchTestToken() + }, throws: { error in + try expectNotFoundError(error) + }) + + try addTestToken() + try #expect(fetchTestToken() == testToken) + + let testToken2 = try updateTestToken("token2") + try #expect(fetchTestToken() == testToken2) + + let testToken3 = try updateTestToken("token3") + try #expect(fetchTestToken() == testToken3) + + try deleteTestToken(throwIfNotFound: true) + #expect(performing: { + try deleteTestToken(throwIfNotFound: true) + }, throws: { error in + try expectNotFoundError(error) + }) + } + + // MARK: - Helper + + private func fetchTestToken() throws -> SecureData { + try KeychainManager.fetch(testToken.keys) + } + + private func addTestToken() throws { + try KeychainManager.add(testToken) + } + + private func deleteTestToken(throwIfNotFound: Bool = false) throws { + try KeychainManager.delete( + testToken.keys, + throwIfNotFound: throwIfNotFound + ) + } + + private func updateTestToken(_ string: String) throws -> SecureData { + var newToken = testToken + newToken.data = Data(string.utf8) + try KeychainManager.update(newToken) + return newToken + } + + private func expectNotFoundError(_ error: Error) throws -> Bool { + let keychainError = try #require(error as? KeychainError) + let status = try #require(keychainError.status) + #expect(status == errSecItemNotFound) + return true + } + + // Needed while init and deinit do not support async + private func runTest( + addBefore: Bool = true, + operation: () throws -> Void + ) throws { + try deleteTestToken() + defer { + try? deleteTestToken() + } + if addBefore { + try addTestToken() + } + try operation() + } +} From 6ff7e94fa55a2ac779b1587251d43aa503c62b05 Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 1 Feb 2025 20:34:50 +0000 Subject: [PATCH 3/7] Add @Sendable to `action` of `onSizeChange` --- Sources/Utilities/SwiftUI/View+Extensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Utilities/SwiftUI/View+Extensions.swift b/Sources/Utilities/SwiftUI/View+Extensions.swift index cfea90e..7175c52 100644 --- a/Sources/Utilities/SwiftUI/View+Extensions.swift +++ b/Sources/Utilities/SwiftUI/View+Extensions.swift @@ -20,7 +20,7 @@ public extension View { } func onSizeChange( - _ action: @escaping (CGSize) -> Void + _ action: @escaping @Sendable (CGSize) -> Void ) -> some View { background( GeometryReader { proxy in From 6095e0f8fe4a11289a8c1159adc10565ba09e1be Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 1 Feb 2025 20:38:55 +0000 Subject: [PATCH 4/7] Create swift.yml --- .github/workflows/swift.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..7027d6e --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,19 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift +on: + push: + branches: ["main"] + pull_request: + branches: ["*"] +jobs: + build: + runs-on: ubuntu-latest # For docker container + container: swift:latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v --configuration release + - name: Test + run: swift test -v --configuration release From 4cb339a89c1fddc67e33c611f73f0c22e506a98b Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 1 Feb 2025 20:52:17 +0000 Subject: [PATCH 5/7] Update swift.yml --- .github/workflows/swift.yml | 8 +++++--- Package.swift | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 7027d6e..311b186 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -4,15 +4,17 @@ name: Swift on: push: - branches: ["main"] + branches: ["develop"] pull_request: branches: ["*"] jobs: build: - runs-on: ubuntu-latest # For docker container - container: swift:latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 + - uses: swift-actions/setup-swift@v2 + with: + swift-version: "6" - name: Build run: swift build -v --configuration release - name: Test diff --git a/Package.swift b/Package.swift index 205c599..1e60743 100644 --- a/Package.swift +++ b/Package.swift @@ -33,10 +33,22 @@ let package = Package( ) ], targets: [ - .target(name: colorUtilities, exclude: ["README.md"]), - .target(name: keychain, exclude: ["README.md"]), - .target(name: utilities, exclude: ["README.md"]), - .target(name: viewRenderer, exclude: ["README.md"]), + .target( + name: colorUtilities, + exclude: ["README.md"] + ), + .target( + name: keychain, + exclude: ["README.md"] + ), + .target( + name: utilities, + exclude: ["README.md"] + ), + .target( + name: viewRenderer, + exclude: ["README.md"] + ), .testTarget( name: "\(colorUtilities)Tests", dependencies: [.byName(name: colorUtilities)] From 9590ed0c5783b08edce40b4eb9c4d8b397c7ec13 Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 1 Feb 2025 21:10:27 +0000 Subject: [PATCH 6/7] Remove swift.yml --- .github/workflows/swift.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml deleted file mode 100644 index 311b186..0000000 --- a/.github/workflows/swift.yml +++ /dev/null @@ -1,21 +0,0 @@ -# This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift - -name: Swift -on: - push: - branches: ["develop"] - pull_request: - branches: ["*"] -jobs: - build: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: swift-actions/setup-swift@v2 - with: - swift-version: "6" - - name: Build - run: swift build -v --configuration release - - name: Test - run: swift test -v --configuration release From 53ebf213c0f93de9b796ebccfdc5b762a5feb314 Mon Sep 17 00:00:00 2001 From: Ben Shutt Date: Sat, 1 Feb 2025 21:10:45 +0000 Subject: [PATCH 7/7] Update @Suite descriptions --- Tests/ColorUtilitiesTests/ColorUtilitiesTests.swift | 3 ++- Tests/UtilitiesTests/DateFormatterExtensionsTests.swift | 3 ++- Tests/UtilitiesTests/StringExtensionsTests.swift | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Tests/ColorUtilitiesTests/ColorUtilitiesTests.swift b/Tests/ColorUtilitiesTests/ColorUtilitiesTests.swift index 687a575..487e91f 100644 --- a/Tests/ColorUtilitiesTests/ColorUtilitiesTests.swift +++ b/Tests/ColorUtilitiesTests/ColorUtilitiesTests.swift @@ -9,7 +9,8 @@ import Testing import SwiftUI @testable import ColorUtilities -@Suite struct ColorUtilitiesTests { +@Suite("Unit tests for ColorUtilities") +struct ColorUtilitiesTests { @Test func hexString() { TestColor.allCases.forEach { testColor in #expect( diff --git a/Tests/UtilitiesTests/DateFormatterExtensionsTests.swift b/Tests/UtilitiesTests/DateFormatterExtensionsTests.swift index ef1b0a2..14690ad 100644 --- a/Tests/UtilitiesTests/DateFormatterExtensionsTests.swift +++ b/Tests/UtilitiesTests/DateFormatterExtensionsTests.swift @@ -9,7 +9,8 @@ import Testing import Foundation @testable import Utilities -@Suite struct DateFormatterExtensionsTests { +@Suite("Unit tests for ISO8601 date formatting") +struct DateFormatterExtensionsTests { private let timeZone = TimeZone(secondsFromGMT: 0) @Test func testISO8601Milliseconds() { diff --git a/Tests/UtilitiesTests/StringExtensionsTests.swift b/Tests/UtilitiesTests/StringExtensionsTests.swift index ea423d7..47b8a71 100644 --- a/Tests/UtilitiesTests/StringExtensionsTests.swift +++ b/Tests/UtilitiesTests/StringExtensionsTests.swift @@ -8,7 +8,8 @@ import Testing @testable import Utilities -@Suite struct StringExtensionsTests { +@Suite("Unit tests for String extensions") +struct StringExtensionsTests { @Test func untrimmed() { #expect("untrimmed".trimmed == "untrimmed") }