pi-remote-ios/Sources/Core/Auth/Keychain.swift

98 lines
2.9 KiB
Swift

// 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<T: Encodable>(_ 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<T: Decodable>(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,
]
}
}