diff --git a/Package.swift b/Package.swift index 4c2ab9b..1e60743 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,29 +16,50 @@ let package = Package( ], products: [ .library( - name: "ColorUtilities", - targets: ["ColorUtilities"] + name: colorUtilities, + targets: [colorUtilities] + ), + .library( + 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: "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/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 new file mode 100644 index 0000000..736f8a0 --- /dev/null +++ b/Sources/Keychain/KeychainError.swift @@ -0,0 +1,34 @@ +// +// 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 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/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 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/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() + } +} 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") }