139 lines
4.6 KiB
Swift
139 lines
4.6 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Unified keychain helper with parameterized service identifier
|
|
///
|
|
/// Provides secure storage for auth tokens, device IDs, and other secrets
|
|
/// used by macOS agent applications.
|
|
///
|
|
/// Example:
|
|
/// ```swift
|
|
/// let keychain = KeychainHelper(service: "com.lilith.sync-agent")
|
|
/// try keychain.save(key: "authToken", data: tokenData)
|
|
/// let token = try keychain.load(key: "authToken")
|
|
/// ```
|
|
public final class KeychainHelper: Sendable {
|
|
private let service: String
|
|
|
|
/// Create a keychain helper for a specific service
|
|
/// - Parameter service: The keychain service identifier (e.g., "com.lilith.sync-agent")
|
|
public init(service: String) {
|
|
self.service = service
|
|
}
|
|
|
|
/// Save data to the keychain
|
|
/// - Parameters:
|
|
/// - key: The key to store under
|
|
/// - data: The data to store
|
|
public func save(key: String, data: Data) throws {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
]
|
|
|
|
// Delete existing item first
|
|
SecItemDelete(query as CFDictionary)
|
|
|
|
var addQuery = query
|
|
addQuery[kSecValueData as String] = data
|
|
|
|
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
guard status == errSecSuccess else {
|
|
throw KeychainError.saveFailed(status: status)
|
|
}
|
|
}
|
|
|
|
/// Save a string to the keychain
|
|
/// - Parameters:
|
|
/// - key: The key to store under
|
|
/// - value: The string value to store
|
|
public func saveString(key: String, value: String) throws {
|
|
guard let data = value.data(using: .utf8) else {
|
|
throw KeychainError.encodingFailed
|
|
}
|
|
try save(key: key, data: data)
|
|
}
|
|
|
|
/// Load data from the keychain
|
|
/// - Parameter key: The key to load
|
|
/// - Returns: The stored data, or nil if not found
|
|
public func load(key: String) throws -> Data? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
|
|
switch status {
|
|
case errSecSuccess:
|
|
return result as? Data
|
|
case errSecItemNotFound:
|
|
return nil
|
|
default:
|
|
throw KeychainError.loadFailed(status: status)
|
|
}
|
|
}
|
|
|
|
/// Load a string from the keychain
|
|
/// - Parameter key: The key to load
|
|
/// - Returns: The stored string, or nil if not found
|
|
public func loadString(key: String) throws -> String? {
|
|
guard let data = try load(key: key) else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
/// Delete an item from the keychain
|
|
/// - Parameter key: The key to delete
|
|
public func delete(key: String) throws {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
]
|
|
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw KeychainError.deleteFailed(status: status)
|
|
}
|
|
}
|
|
|
|
/// Delete all items for this service
|
|
public func deleteAll() throws {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
]
|
|
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw KeychainError.deleteFailed(status: status)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keychain operation errors
|
|
public enum KeychainError: LocalizedError {
|
|
case saveFailed(status: OSStatus)
|
|
case loadFailed(status: OSStatus)
|
|
case deleteFailed(status: OSStatus)
|
|
case encodingFailed
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .saveFailed(let status):
|
|
return "Keychain save failed with status: \(status)"
|
|
case .loadFailed(let status):
|
|
return "Keychain load failed with status: \(status)"
|
|
case .deleteFailed(let status):
|
|
return "Keychain delete failed with status: \(status)"
|
|
case .encodingFailed:
|
|
return "Failed to encode string to UTF-8 data"
|
|
}
|
|
}
|
|
}
|