// Sources/Core/Auth/Keychain.swift // T-2.2: Generic Keychain wrapper (kSecClassGenericPassword) import Foundation import Security // MARK: - Errors enum KeychainError: Error, Sendable { case notFound case encodingFailed case saveFailed(OSStatus) } // MARK: - Keychain /// Thread-safe Keychain wrapper for `Codable` values stored as JSON data. /// All items use `kSecClassGenericPassword` with a caller-supplied service key. final class Keychain: Sendable { static let shared = Keychain() /// Canonical key used to store the active `SidecarCredential`. static let credentialKey = "piremote.credential" private init() {} // MARK: Save /// Encodes `value` as JSON and upserts it into the Keychain under `key`. func save(_ value: T, key: String) throws { let data: Data do { data = try JSONEncoder().encode(value) } catch { throw KeychainError.encodingFailed } // Attempt update first; if item doesn't exist, add it. let query = baseQuery(for: key) let updateAttributes: [CFString: Any] = [kSecValueData: data] let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) if updateStatus == errSecItemNotFound { var addQuery = query addQuery[kSecValueData] = data let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { throw KeychainError.saveFailed(addStatus) } } else if updateStatus != errSecSuccess { throw KeychainError.saveFailed(updateStatus) } } // MARK: Load /// Loads and JSON-decodes a value of type `T` stored under `key`. func load(key: String) throws -> T { var query = baseQuery(for: key) query[kSecReturnData] = true query[kSecMatchLimit] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess else { throw KeychainError.notFound } guard let data = result as? Data else { throw KeychainError.notFound } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw KeychainError.encodingFailed } } // MARK: Delete /// Removes the item stored under `key` (no-op if absent). func delete(key: String) { SecItemDelete(baseQuery(for: key) as CFDictionary) } // MARK: - Private helpers private func baseQuery(for key: String) -> [CFString: Any] { [ kSecClass: kSecClassGenericPassword, kSecAttrService: "de.vpsj.pi-remote", kSecAttrAccount: key, kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] } }