Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

import PackageDescription

let colorUtilities = "ColorUtilities"
let keychain = "Keychain"
let utilities = "Utilities"
let viewRenderer = "ViewRenderer"

let package = Package(
name: "Utilities",
platforms: [
Expand All @@ -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)]
)
]
)
13 changes: 13 additions & 0 deletions Sources/Keychain/KeychainActor.swift
Original file line number Diff line number Diff line change
@@ -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()
}
34 changes: 34 additions & 0 deletions Sources/Keychain/KeychainError.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 25 additions & 0 deletions Sources/Keychain/KeychainItem/KeychainModel.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
90 changes: 90 additions & 0 deletions Sources/Keychain/KeychainItem/SecureData.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
114 changes: 114 additions & 0 deletions Sources/Keychain/KeychainManager.swift
Original file line number Diff line number Diff line change
@@ -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<T: KeychainModel>(
_ keys: T.PrimaryKeys
) throws(KeychainError) -> T {
try T(ref: fetch(query: keys.fetchQuery), keys: keys)
}

public static func add<T: KeychainModel>(
_ model: T
) throws(KeychainError) {
try add(query: model.addQuery)
}

public static func update<T: KeychainModel>(
_ 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<T: KeychainPrimaryKeysModel>(
_ keys: T,
throwIfNotFound: Bool = false
) throws(KeychainError) {
try delete(
query: keys.deleteQuery,
throwIfNotFound: throwIfNotFound
)
}

// MARK: - Query

public static func fetch<T>(
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)
}
}
}
18 changes: 18 additions & 0 deletions Sources/Keychain/Query/KeychainClass.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
18 changes: 18 additions & 0 deletions Sources/Keychain/Query/KeychainMatchLimit.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading