import Foundation @preconcurrency import Security /// Persistence for accounts and their tokens. The Keychain implementation /// is the real one; tests use an in-memory stand-in so no test touches the /// host Keychain. nonisolated protocol TokenStore: Sendable { func saveToken(_ token: String, for accountID: String) throws func token(for accountID: String) -> String? func deleteToken(for accountID: String) func saveAccounts(_ accounts: [Account]) throws func loadAccounts() -> [Account] } enum TokenStoreError: LocalizedError, Sendable { case keychain(OSStatus) var errorDescription: String? { switch self { case .keychain(let status): "Could not store the token securely (\(status))." } } } /// Tokens and the account list live in the Keychain and nowhere else — /// nothing git tracks, nothing in UserDefaults, no iCloud sync /// (ThisDeviceOnly): a token names this phone, not the account. nonisolated struct KeychainTokenStore: TokenStore { private static let service = "org.gitbay.gitbay" private static let accountsKey = "accounts" func saveToken(_ token: String, for accountID: String) throws { try save(Data(token.utf8), account: "token." + accountID) } func token(for accountID: String) -> String? { load(account: "token." + accountID).flatMap { String(data: $0, encoding: .utf8) } } func deleteToken(for accountID: String) { delete(account: "token." + accountID) } func saveAccounts(_ accounts: [Account]) throws { try save(JSONEncoder().encode(accounts), account: Self.accountsKey) } func loadAccounts() -> [Account] { guard let data = load(account: Self.accountsKey), let accounts = try? JSONDecoder().decode([Account].self, from: data) else { return [] } return accounts } // MARK: - SecItem private func save(_ data: Data, account: String) throws { delete(account: account) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw TokenStoreError.keychain(status) } } private func load(account: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil } return result as? Data } private func delete(account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.service, kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) } }