Files
SonarDesk/SonarDesk-iOS/Shared/Services/KeychainService.swift
2026-10-06 09:13:11 +01:00

104 lines
4.0 KiB
Swift

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)
}
}