A WIP swift OAuth Library that one day I'll get back to
1//
2// File.swift
3// Gulliver
4//
5// Created by Bailey Townsend on 1/20/26.
6//
7
8import Foundation
9import CryptoKit
10
11enum SecureStore: String {
12 case state = "state"
13 case dpop = "dpop"
14}
15
16
17
18public func getStateKeychainStore() -> KeychainStorage {
19 return KeychainStorage(namespace: "state")
20}
21
22public func getSessionKeychainStore() -> KeychainStorage {
23 return KeychainStorage(namespace: "session")
24}
25
26
27
28public class KeychainStorage {
29 let namespace: String
30 private let accessGroup: String?
31
32 private static var defaultAccessibility: CFString {
33#if os(iOS)
34 return kSecAttrAccessibleAfterFirstUnlock
35#elseif os(macOS)
36 return kSecAttrAccessibleAfterFirstUnlock
37#endif
38 }
39
40 private static func platformSpecificAttributes() -> [String: Any] {
41 var attributes: [String: Any] = [:]
42
43#if os(macOS)
44 // Disable iCloud sync for app-specific keychain items on macOS
45 attributes[kSecAttrSynchronizable as String] = false
46#endif
47
48 return attributes
49 }
50
51
52 public init(namespace: String = "default", accessGroup: String? = nil) {
53 self.namespace = namespace
54 self.accessGroup = accessGroup
55 }
56
57 private func getKey(did: String, store: SecureStore ) -> String {
58 "\(did):\(store)"
59 }
60
61
62
63 func store(key: String, value: Data, namespace: String) throws {
64 let namespacedKey = "\(namespace):\(key)"
65
66 var query: [String: Any] = [
67 kSecClass as String: kSecClassGenericPassword,
68 kSecAttrAccount as String: namespacedKey,
69 kSecValueData as String: value,
70 kSecAttrAccessible as String: Self.defaultAccessibility,
71 ]
72
73 // Add platform-specific attributes
74 query.merge(Self.platformSpecificAttributes()) { _, new in new }
75
76 // Delete any existing item with the same key
77 let deleteStatus = SecItemDelete(query as CFDictionary)
78 if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
79 throw KeychainStorageError.deleteError(Int(deleteStatus))
80 }
81
82 // Add the new item to the keychain
83 let status = SecItemAdd(query as CFDictionary, nil)
84 if status == errSecDuplicateItem {
85 throw KeychainStorageError.keyStoreError(Int(status))
86 }
87 guard status == errSecSuccess else {
88 throw KeychainStorageError.keyStoreError(Int(status))
89 }
90 }
91
92 func retrieve(key: String, namespace: String) throws -> Data {
93 let namespacedKey = "\(namespace):\(key)"
94
95 let query: [String: Any] = [
96 kSecClass as String: kSecClassGenericPassword,
97 kSecAttrAccount as String: namespacedKey,
98 kSecReturnData as String: kCFBooleanTrue!,
99 kSecMatchLimit as String: kSecMatchLimitOne,
100 ]
101
102 var item: CFTypeRef?
103 let status = SecItemCopyMatching(query as CFDictionary, &item)
104
105 if status == errSecItemNotFound {
106 throw KeychainStorageError.retrieveError(Int(status))
107 }
108
109 guard status == errSecSuccess else {
110 throw KeychainStorageError.retrieveError(Int(status))
111 }
112 guard let data = item as? Data else {
113 throw KeychainStorageError.dataFormatError
114 }
115
116 return data
117 }
118
119 func delete(key: String, namespace: String) throws {
120 let namespacedKey = "\(namespace):\(key)"
121
122
123 let query: [String: Any] = [
124 kSecClass as String: kSecClassGenericPassword,
125 kSecAttrAccount as String: namespacedKey,
126 ]
127
128 let status = SecItemDelete(query as CFDictionary)
129
130 if status != errSecSuccess, status != errSecItemNotFound {
131 throw KeychainStorageError.deleteError(Int(status))
132 }
133 }
134
135 func deleteAll(namespace: String) throws {
136 // Handle generic passwords first
137 let genericSuccess = try deleteGenericPasswords(withNamespacePrefix: namespace)
138
139 // Then handle crypto keys
140 let keysSuccess = try deleteCryptoKeys(withNamespacePrefix: namespace)
141
142 guard genericSuccess, keysSuccess else {
143 throw KeychainStorageError.deleteError(-1)
144 }
145 }
146
147 private func deleteGenericPasswords(withNamespacePrefix namespace: String) throws -> Bool {
148 // Query to get all generic passwords
149 let query: [String: Any] = [
150 kSecClass as String: kSecClassGenericPassword,
151 kSecMatchLimit as String: kSecMatchLimitAll,
152 kSecReturnAttributes as String: true,
153 ]
154
155 var result: AnyObject?
156 let status = SecItemCopyMatching(query as CFDictionary, &result)
157
158 if status == errSecSuccess, let items = result as? [[String: Any]] {
159 var allSucceeded = true
160 var matchedCount = 0
161
162 // Filter and delete items that match our namespace
163 for item in items {
164 if let account = item[kSecAttrAccount as String] as? String,
165 account.hasPrefix("\(namespace):")
166 {
167 matchedCount += 1
168
169 let deleteQuery: [String: Any] = [
170 kSecClass as String: kSecClassGenericPassword,
171 kSecAttrAccount as String: account,
172 ]
173
174 let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
175 if deleteStatus != errSecSuccess {
176 allSucceeded = false
177 }
178 }
179 }
180
181 return allSucceeded
182 } else if status == errSecItemNotFound {
183
184 return true
185 } else {
186 //TODO throw or just sliently fail?
187 return false
188 }
189 }
190
191 private func deleteCryptoKeys(withNamespacePrefix namespace: String) throws -> Bool {
192 // Query to get all keys
193 let query: [String: Any] = [
194 kSecClass as String: kSecClassKey,
195 kSecMatchLimit as String: kSecMatchLimitAll,
196 kSecReturnAttributes as String: true,
197 ]
198
199 var result: AnyObject?
200 let status = SecItemCopyMatching(query as CFDictionary, &result)
201
202 if status == errSecSuccess, let items = result as? [[String: Any]] {
203 var allSucceeded = true
204 var matchedCount = 0
205
206 // Filter and delete keys
207 for item in items {
208 // For keys, check the application tag
209 if let tagData = item[kSecAttrApplicationTag as String] as? Data,
210 let tagString = String(data: tagData, encoding: .utf8),
211 tagString.hasPrefix("\(namespace).")
212 {
213 matchedCount += 1
214
215 let deleteQuery: [String: Any] = [
216 kSecClass as String: kSecClassKey,
217 kSecAttrApplicationTag as String: tagData,
218 ]
219
220 let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
221 if deleteStatus != errSecSuccess {
222 allSucceeded = false
223 }
224 }
225 }
226
227 return allSucceeded
228 } else if status == errSecItemNotFound {
229 return true
230 } else {
231 return false
232 }
233 }
234
235 func storeDPoPKey(_ key: P256.Signing.PrivateKey, keyTag: String) throws {
236 guard let tagData = keyTag.data(using: .utf8) else {
237 throw KeychainStorageError.dataFormatError
238 }
239
240 var query: [String: Any] = [
241 kSecClass as String: kSecClassKey,
242 kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
243 kSecAttrKeySizeInBits as String: 256,
244 kSecAttrApplicationTag as String: tagData,
245 kSecValueData as String: key.x963Representation,
246 kSecAttrAccessible as String: Self.defaultAccessibility,
247 ]
248
249 // Delete any existing key first
250 let deleteQuery: [String: Any] = [
251 kSecClass as String: kSecClassKey,
252 kSecAttrApplicationTag as String: tagData,
253 ]
254
255 let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
256
257 // Add the new key
258 let status = SecItemAdd(query as CFDictionary, nil)
259 if status == errSecDuplicateItem {
260 // Try update
261 let updateAttributes: [String: Any] = [
262 kSecValueData as String: key.x963Representation,
263 ]
264 let updateStatus = SecItemUpdate(deleteQuery as CFDictionary, updateAttributes as CFDictionary)
265 guard updateStatus == errSecSuccess else {
266 throw KeychainStorageError.keyStoreError(Int(updateStatus))
267 }
268 } else if status != errSecSuccess {
269 throw KeychainStorageError.keyStoreError(Int(status))
270 }
271 }
272
273 func retrieveDPoPKey(keyTag: String) throws -> P256.Signing.PrivateKey {
274 guard let tagData = keyTag.data(using: .utf8) else {
275 throw KeychainStorageError.dataFormatError
276 }
277
278 let query: [String: Any] = [
279 kSecClass as String: kSecClassKey,
280 kSecAttrApplicationTag as String: tagData,
281 kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
282 kSecReturnData as String: kCFBooleanTrue!,
283 kSecMatchLimit as String: kSecMatchLimitOne,
284 ]
285
286 var item: CFTypeRef?
287 let status = SecItemCopyMatching(query as CFDictionary, &item)
288
289 guard status == errSecSuccess, let data = item as? Data else {
290 throw KeychainStorageError.retrieveError(Int(status))
291 }
292
293 return try P256.Signing.PrivateKey(x963Representation: data)
294
295 }
296
297 func deleteDPoPKey(keyTag: String) throws {
298 guard let tagData = keyTag.data(using: .utf8) else {
299 throw KeychainStorageError.dataFormatError
300 }
301
302 let query: [String: Any] = [
303 kSecClass as String: kSecClassKey,
304 kSecAttrApplicationTag as String: tagData,
305 ]
306
307 let status = SecItemDelete(query as CFDictionary)
308 if status != errSecSuccess, status != errSecItemNotFound {
309 throw KeychainStorageError.deleteError(Int(status))
310 }
311 }
312
313}
314
315
316
317public enum KeychainStorageError: Error {
318 case deleteError(Int)
319 case keyStoreError(Int)
320 case retrieveError(Int)
321 case dataFormatError
322
323}