A WIP swift OAuth Library that one day I'll get back to
at main 323 lines 11 kB view raw
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}