98 lines
2.9 KiB
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,
|
|
]
|
|
}
|
|
}
|