krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.9.1: Hutch/Extensions/KeychainHelper.swift · raw

  1import Foundation
  2@preconcurrency import Security
  3
  4enum KeychainHelper: Sendable {
  5
  6    private static let service = "net.cleberg.Hutch"
  7    private static let tokenAccount = "srht-access-token"
  8    private static let accountsAccount = "srht-accounts"
  9
 10    // MARK: - Save
 11
 12    static func saveToken(_ token: String) throws {
 13        guard let data = token.data(using: .utf8) else {
 14            throw KeychainError.encodingFailed
 15        }
 16
 17        // Delete any existing item first
 18        let deleteQuery: [String: Any] = [
 19            kSecClass as String: kSecClassGenericPassword,
 20            kSecAttrService as String: service,
 21            kSecAttrAccount as String: tokenAccount
 22        ]
 23        SecItemDelete(deleteQuery as CFDictionary)
 24
 25        let addQuery: [String: Any] = [
 26            kSecClass as String: kSecClassGenericPassword,
 27            kSecAttrService as String: service,
 28            kSecAttrAccount as String: tokenAccount,
 29            kSecValueData as String: data,
 30            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
 31        ]
 32
 33        let status = SecItemAdd(addQuery as CFDictionary, nil)
 34        guard status == errSecSuccess else {
 35            throw KeychainError.saveFailed(status)
 36        }
 37    }
 38
 39    // MARK: - Load
 40
 41    static func loadToken() -> String? {
 42        let query: [String: Any] = [
 43            kSecClass as String: kSecClassGenericPassword,
 44            kSecAttrService as String: service,
 45            kSecAttrAccount as String: tokenAccount,
 46            kSecReturnData as String: true,
 47            kSecMatchLimit as String: kSecMatchLimitOne
 48        ]
 49
 50        var result: AnyObject?
 51        let status = SecItemCopyMatching(query as CFDictionary, &result)
 52
 53        guard status == errSecSuccess,
 54              let data = result as? Data,
 55              let token = String(data: data, encoding: .utf8) else {
 56            return nil
 57        }
 58        return token
 59    }
 60
 61    // MARK: - Multi-account list
 62
 63    static func saveAccounts(_ accounts: [AccountEntry]) throws {
 64        let data = try JSONEncoder().encode(accounts)
 65
 66        let deleteQuery: [String: Any] = [
 67            kSecClass as String: kSecClassGenericPassword,
 68            kSecAttrService as String: service,
 69            kSecAttrAccount as String: accountsAccount
 70        ]
 71        SecItemDelete(deleteQuery as CFDictionary)
 72
 73        let addQuery: [String: Any] = [
 74            kSecClass as String: kSecClassGenericPassword,
 75            kSecAttrService as String: service,
 76            kSecAttrAccount as String: accountsAccount,
 77            kSecValueData as String: data,
 78            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
 79        ]
 80        let status = SecItemAdd(addQuery as CFDictionary, nil)
 81        guard status == errSecSuccess else {
 82            throw KeychainError.saveFailed(status)
 83        }
 84    }
 85
 86    static func loadAccounts() -> [AccountEntry] {
 87        let query: [String: Any] = [
 88            kSecClass as String: kSecClassGenericPassword,
 89            kSecAttrService as String: service,
 90            kSecAttrAccount as String: accountsAccount,
 91            kSecReturnData as String: true,
 92            kSecMatchLimit as String: kSecMatchLimitOne
 93        ]
 94        var result: AnyObject?
 95        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
 96              let data = result as? Data else { return [] }
 97        return (try? JSONDecoder().decode([AccountEntry].self, from: data)) ?? []
 98    }
 99
100    // MARK: - Delete
101
102    static func deleteToken() throws {
103        let query: [String: Any] = [
104            kSecClass as String: kSecClassGenericPassword,
105            kSecAttrService as String: service,
106            kSecAttrAccount as String: tokenAccount
107        ]
108
109        let status = SecItemDelete(query as CFDictionary)
110        guard status == errSecSuccess || status == errSecItemNotFound else {
111            throw KeychainError.deleteFailed(status)
112        }
113    }
114
115    static func deleteAll() throws {
116        let query: [String: Any] = [
117            kSecClass as String: kSecClassGenericPassword,
118            kSecAttrService as String: service
119        ]
120
121        let status = SecItemDelete(query as CFDictionary)
122        guard status == errSecSuccess || status == errSecItemNotFound else {
123            throw KeychainError.deleteFailed(status)
124        }
125    }
126}
127
128enum KeychainError: LocalizedError {
129    case encodingFailed
130    case saveFailed(OSStatus)
131    case deleteFailed(OSStatus)
132
133    var errorDescription: String? {
134        switch self {
135        case .encodingFailed:
136            "Failed to encode token data."
137        case .saveFailed(let status):
138            "Keychain save failed with status \(status)."
139        case .deleteFailed(let status):
140            "Keychain delete failed with status \(status)."
141        }
142    }
143}