krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.6.0: 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}