RadrootsAppleKeychainSecureStore.swift (7419B)
1 import Foundation 2 import Security 3 4 public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unchecked Sendable { 5 public let servicePrefix: String 6 private let accessControlFactory: (RadrootsKeychainSecretPolicyMapping) throws -> SecAccessControl 7 8 public init(servicePrefix: String = "org.radroots.kit.secure-store") { 9 self.servicePrefix = servicePrefix 10 accessControlFactory = Self.makeAccessControl(for:) 11 } 12 13 init( 14 servicePrefix: String = "org.radroots.kit.secure-store", 15 accessControlFactory: @escaping (RadrootsKeychainSecretPolicyMapping) throws -> SecAccessControl 16 ) { 17 self.servicePrefix = servicePrefix 18 self.accessControlFactory = accessControlFactory 19 } 20 21 public func put( 22 _ value: Data, 23 for key: RadrootsSecureStoreKey, 24 policy: RadrootsSecretAccessPolicy = .secureLocalSecret 25 ) throws { 26 let attributes = try mutationAttributes(value, policy: policy) 27 var addQuery = try baseQuery(for: key) 28 addQuery.merge(attributes) { _, new in new } 29 30 let addStatus = SecItemAdd(addQuery as CFDictionary, nil) 31 switch addStatus { 32 case errSecSuccess: 33 return 34 case errSecDuplicateItem: 35 break 36 default: 37 throw Self.mapStatus(addStatus) 38 } 39 40 let updateStatus = try SecItemUpdate( 41 baseQuery(for: key) as CFDictionary, attributes as CFDictionary) 42 guard updateStatus == errSecSuccess else { 43 throw Self.mapStatus(updateStatus) 44 } 45 } 46 47 public func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { 48 var query = try baseQuery(for: key) 49 query[kSecMatchLimit as String] = kSecMatchLimitOne 50 51 let status = SecItemCopyMatching(query as CFDictionary, nil) 52 if status == errSecItemNotFound { 53 return false 54 } 55 guard status == errSecSuccess else { 56 throw Self.mapStatus(status) 57 } 58 return true 59 } 60 61 public func get(_ key: RadrootsSecureStoreKey) throws -> Data? { 62 var query = try baseQuery(for: key) 63 query[kSecReturnData as String] = true 64 query[kSecMatchLimit as String] = kSecMatchLimitOne 65 66 var result: CFTypeRef? 67 let status = SecItemCopyMatching(query as CFDictionary, &result) 68 if status == errSecItemNotFound { 69 return nil 70 } 71 guard status == errSecSuccess else { 72 throw Self.mapStatus(status) 73 } 74 guard let data = result as? Data else { 75 throw RadrootsAppleSecurityError.permanentFailure 76 } 77 return data 78 } 79 80 public func delete(_ key: RadrootsSecureStoreKey) throws { 81 let status = try SecItemDelete(baseQuery(for: key) as CFDictionary) 82 guard status == errSecSuccess || status == errSecItemNotFound else { 83 throw Self.mapStatus(status) 84 } 85 } 86 87 public func deleteNamespace(_ namespace: String) throws { 88 let status = try SecItemDelete(namespaceQuery(namespace) as CFDictionary) 89 guard status == errSecSuccess || status == errSecItemNotFound else { 90 throw Self.mapStatus(status) 91 } 92 } 93 94 func baseQuery(for key: RadrootsSecureStoreKey) throws -> [String: Any] { 95 let normalizedKey = try key.normalized() 96 return try [ 97 kSecClass as String: kSecClassGenericPassword, 98 kSecAttrService as String: normalizedKey.serviceName(servicePrefix: servicePrefix), 99 kSecAttrAccount as String: normalizedKey.name, 100 ] 101 } 102 103 func namespaceQuery(_ namespace: String) throws -> [String: Any] { 104 try [ 105 kSecClass as String: kSecClassGenericPassword, 106 kSecAttrService as String: RadrootsSecureStoreKey.serviceName( 107 servicePrefix: servicePrefix, 108 namespace: namespace 109 ), 110 ] 111 } 112 113 func accessibilityConstant(for policy: RadrootsSecretAccessPolicy) -> CFString { 114 switch (policy.accessibility, policy.deviceLocalOnly) { 115 case (.whenUnlocked, true): 116 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 117 case (.whenUnlocked, false): 118 kSecAttrAccessibleWhenUnlocked 119 case (.afterFirstUnlock, true): 120 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 121 case (.afterFirstUnlock, false): 122 kSecAttrAccessibleAfterFirstUnlock 123 } 124 } 125 126 func keychainPolicyMapping(for policy: RadrootsSecretAccessPolicy) 127 -> RadrootsKeychainSecretPolicyMapping 128 { 129 RadrootsKeychainSecretPolicyMapping( 130 accessibilityConstant: accessibilityConstant(for: policy), 131 usesAccessControl: policy.userPresenceRequired, 132 accessControlFlags: policy.userPresenceRequired ? .userPresence : [] 133 ) 134 } 135 136 func accessControl(for policy: RadrootsSecretAccessPolicy) throws -> SecAccessControl { 137 try accessControl(for: keychainPolicyMapping(for: policy)) 138 } 139 140 func accessControl(for mapping: RadrootsKeychainSecretPolicyMapping) throws -> SecAccessControl { 141 do { 142 return try accessControlFactory(mapping) 143 } catch let error as RadrootsAppleSecurityError { 144 throw error 145 } catch { 146 throw RadrootsAppleSecurityError.keychainFailure 147 } 148 } 149 150 private func mutationAttributes( 151 _ value: Data, 152 policy: RadrootsSecretAccessPolicy 153 ) throws -> [String: Any] { 154 let mapping = keychainPolicyMapping(for: policy) 155 var attributes: [String: Any] = [ 156 kSecValueData as String: value 157 ] 158 if mapping.usesAccessControl { 159 attributes[kSecAttrAccessControl as String] = try accessControl(for: mapping) 160 } else { 161 attributes[kSecAttrAccessible as String] = mapping.accessibilityConstant 162 } 163 return attributes 164 } 165 166 private static func makeAccessControl(for mapping: RadrootsKeychainSecretPolicyMapping) throws 167 -> SecAccessControl 168 { 169 guard 170 let accessControl = SecAccessControlCreateWithFlags( 171 nil, 172 mapping.accessibilityConstant, 173 mapping.accessControlFlags, 174 nil 175 ) 176 else { 177 throw RadrootsAppleSecurityError.invalidRequest 178 } 179 return accessControl 180 } 181 182 static func mapStatus(_ status: OSStatus) -> RadrootsAppleSecurityError { 183 switch status { 184 case errSecItemNotFound: 185 .notFound 186 case errSecAuthFailed: 187 .permissionDenied 188 case errSecInteractionNotAllowed: 189 .transientFailure 190 case errSecUserCanceled: 191 .userCancelled 192 case errSecNotAvailable: 193 .unavailable 194 default: 195 .keychainFailure 196 } 197 } 198 } 199 200 struct RadrootsKeychainSecretPolicyMapping: Equatable { 201 let accessibilityConstant: CFString 202 let usesAccessControl: Bool 203 let accessControlFlags: SecAccessControlCreateFlags 204 205 static func == ( 206 lhs: RadrootsKeychainSecretPolicyMapping, 207 rhs: RadrootsKeychainSecretPolicyMapping 208 ) -> Bool { 209 String(lhs.accessibilityConstant) == String(rhs.accessibilityConstant) 210 && lhs.usesAccessControl == rhs.usesAccessControl 211 && lhs.accessControlFlags == rhs.accessControlFlags 212 } 213 }