vOOice/VoiceInk/Services/KeychainService.swift
Beingpax 948033ac28 Migrate API key storage to Keychain with iCloud sync
Move API keys from UserDefaults to secure Keychain storage. Add KeychainService and APIKeyManager for centralized key management. Enable iCloud Keychain sync for cross-device sharing between macOS and iOS.
2026-01-05 22:28:34 +05:45

118 lines
4.0 KiB
Swift

import Foundation
import Security
import os
/// Securely stores and retrieves API keys using Keychain with iCloud sync.
final class KeychainService {
static let shared = KeychainService()
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "KeychainService")
private let accessGroup = "com.prakashjoshipax.VoiceInk"
private let service = "com.prakashjoshipax.VoiceInk"
private init() {}
// MARK: - Public API
/// Saves a string value to Keychain.
@discardableResult
func save(_ value: String, forKey key: String, syncable: Bool = true) -> Bool {
guard let data = value.data(using: .utf8) else {
logger.error("Failed to convert value to data for key: \(key)")
return false
}
return save(data: data, forKey: key, syncable: syncable)
}
/// Saves data to Keychain.
@discardableResult
func save(data: Data, forKey key: String, syncable: Bool = true) -> Bool {
// First, try to delete any existing item to avoid duplicates
delete(forKey: key, syncable: syncable)
var query = baseQuery(forKey: key, syncable: syncable)
query[kSecValueData as String] = data
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
logger.info("Successfully saved keychain item for key: \(key)")
return true
} else {
logger.error("Failed to save keychain item for key: \(key), status: \(status)")
return false
}
}
/// Retrieves a string value from Keychain.
func getString(forKey key: String, syncable: Bool = true) -> String? {
guard let data = getData(forKey: key, syncable: syncable) else {
return nil
}
return String(data: data, encoding: .utf8)
}
/// Retrieves data from Keychain.
func getData(forKey key: String, syncable: Bool = true) -> Data? {
var query = baseQuery(forKey: key, syncable: syncable)
query[kSecReturnData as String] = kCFBooleanTrue
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess {
return result as? Data
} else if status != errSecItemNotFound {
logger.error("Failed to retrieve keychain item for key: \(key), status: \(status)")
}
return nil
}
/// Deletes an item from Keychain.
@discardableResult
func delete(forKey key: String, syncable: Bool = true) -> Bool {
let query = baseQuery(forKey: key, syncable: syncable)
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
if status == errSecSuccess {
logger.info("Successfully deleted keychain item for key: \(key)")
}
return true
} else {
logger.error("Failed to delete keychain item for key: \(key), status: \(status)")
return false
}
}
/// Checks if a key exists in Keychain.
func exists(forKey key: String, syncable: Bool = true) -> Bool {
var query = baseQuery(forKey: key, syncable: syncable)
query[kSecReturnData as String] = kCFBooleanFalse
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
// MARK: - Private Helpers
/// Creates base Keychain query dictionary.
private func baseQuery(forKey key: String, syncable: Bool) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: accessGroup,
kSecUseDataProtectionKeychain as String: true
]
if syncable {
query[kSecAttrSynchronizable as String] = kCFBooleanTrue
}
return query
}
}