NavidromeApp/Shared/Storage/KeychainHelper.swift
Dallas Groot 0730fa11f8 bug fixes
2026-04-11 15:09:06 -07:00

83 lines
2.8 KiB
Swift

import Foundation
import Security
/// Simple Keychain wrapper for storing server passwords securely.
/// Passwords are stored per server UUID so each server has its own entry.
/// Falls back gracefully if Keychain is unavailable (e.g. simulator sandbox
/// edge cases), the caller handles the nil and falls back to UserDefaults.
enum KeychainHelper {
private static let service = "ca.dallasgroot.navidromeplayer"
// MARK: - Save
/// Store a password for the given server ID. Overwrites any existing entry.
@discardableResult
static func savePassword(_ password: String, for serverId: UUID) -> Bool {
let key = keychainKey(for: serverId)
guard let data = password.data(using: .utf8) else { return false }
// Delete existing first SecItemAdd fails if the key already exists
deletePassword(for: serverId)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecValueData: data,
// Accessible after first unlock allows background audio session
// to access credentials without requiring the device to be unlocked.
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}
// MARK: - Load
/// Retrieve the password for the given server ID. Returns nil if not found.
static func loadPassword(for serverId: UUID) -> String? {
let key = keychainKey(for: serverId)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8) else {
return nil
}
return password
}
// MARK: - Delete
/// Remove the stored password for the given server ID.
@discardableResult
static func deletePassword(for serverId: UUID) -> Bool {
let key = keychainKey(for: serverId)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
// MARK: - Private
private static func keychainKey(for serverId: UUID) -> String {
"server.\(serverId.uuidString).password"
}
}