import Foundation import Security enum KeychainService { static let service = "net.absdev.SonarDesk" static func saveToken(_ token: String, serverID: UUID) throws { try saveLocal(token, account: serverID.uuidString) } static func token(serverID: UUID) -> String? { readWithLegacyMigration(account: serverID.uuidString) } static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws { try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)") } static func projectToken(serverID: UUID, projectKey: String) -> String? { readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)") } static func deleteToken(serverID: UUID) { deleteAll(account: serverID.uuidString) } private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: synchronizable] } private static func saveLocal(_ value: String, account: String) throws { let clean = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !clean.isEmpty else { throw KeychainError.emptyToken } let data = Data(clean.utf8) let localQuery = query(account: account, synchronizable: kCFBooleanFalse) let updateStatus = SecItemUpdate(localQuery as CFDictionary, [kSecValueData as String: data] as CFDictionary) if updateStatus == errSecSuccess { return } guard updateStatus == errSecItemNotFound else { throw KeychainError.status(updateStatus) } var add = localQuery add[kSecValueData as String] = data add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let status = SecItemAdd(add as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.status(status) } } private static func readWithLegacyMigration(account: String) -> String? { if let local = read(account: account, synchronizable: kCFBooleanFalse) { return local } guard let legacy = read(account: account, synchronizable: kSecAttrSynchronizableAny) else { return nil } // Best-effort migration from older iCloud/synchronizable Keychain entries. try? saveLocal(legacy, account: account) return legacy } private static func read(account: String, synchronizable: CFTypeRef) -> String? { var lookup = query(account: account, synchronizable: synchronizable) lookup[kSecReturnData as String] = true lookup[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess, let data = item as? Data, let value = String(data: data, encoding: .utf8), !value.isEmpty else { return nil } return value } private static func deleteAll(account: String) { SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary) } } enum KeychainError: LocalizedError { case emptyToken case status(OSStatus) var errorDescription: String? { switch self { case .emptyToken: return "The SonarQube token cannot be empty." case .status(let status): return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error (\(status))." } } } extension KeychainService { private static let licenseTokenAccount = "sonardesk.license-token" static func saveLicenseToken(_ token: String) throws { try saveLocal(token, account: licenseTokenAccount) } static func licenseToken() -> String? { readWithLegacyMigration(account: licenseTokenAccount) } static func deleteLicenseToken() { deleteAll(account: licenseTokenAccount) } }