RadrootsAppleKeychainSecureStore.swift (7811B)
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 self.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, defaultMessage: "keychain write failed") 38 } 39 40 let updateStatus = SecItemUpdate(try baseQuery(for: key) as CFDictionary, attributes as CFDictionary) 41 guard updateStatus == errSecSuccess else { 42 throw Self.mapStatus(updateStatus, defaultMessage: "keychain update failed") 43 } 44 } 45 46 public func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { 47 var query = try baseQuery(for: key) 48 query[kSecMatchLimit as String] = kSecMatchLimitOne 49 50 let status = SecItemCopyMatching(query as CFDictionary, nil) 51 if status == errSecItemNotFound { 52 return false 53 } 54 guard status == errSecSuccess else { 55 throw Self.mapStatus(status, defaultMessage: "keychain presence check failed") 56 } 57 return true 58 } 59 60 public func get(_ key: RadrootsSecureStoreKey) throws -> Data? { 61 var query = try baseQuery(for: key) 62 query[kSecReturnData as String] = true 63 query[kSecMatchLimit as String] = kSecMatchLimitOne 64 65 var result: CFTypeRef? 66 let status = SecItemCopyMatching(query as CFDictionary, &result) 67 if status == errSecItemNotFound { 68 return nil 69 } 70 guard status == errSecSuccess else { 71 throw Self.mapStatus(status, defaultMessage: "keychain read failed") 72 } 73 guard let data = result as? Data else { 74 throw RadrootsAppleSecurityError.permanentFailure("keychain returned an invalid value type") 75 } 76 return data 77 } 78 79 public func delete(_ key: RadrootsSecureStoreKey) throws { 80 let status = SecItemDelete(try baseQuery(for: key) as CFDictionary) 81 guard status == errSecSuccess || status == errSecItemNotFound else { 82 throw Self.mapStatus(status, defaultMessage: "keychain delete failed") 83 } 84 } 85 86 public func deleteNamespace(_ namespace: String) throws { 87 let status = SecItemDelete(try namespaceQuery(namespace) as CFDictionary) 88 guard status == errSecSuccess || status == errSecItemNotFound else { 89 throw Self.mapStatus(status, defaultMessage: "keychain namespace delete failed") 90 } 91 } 92 93 func baseQuery(for key: RadrootsSecureStoreKey) throws -> [String: Any] { 94 let normalizedKey = try key.normalized() 95 return [ 96 kSecClass as String: kSecClassGenericPassword, 97 kSecAttrService as String: try normalizedKey.serviceName(servicePrefix: servicePrefix), 98 kSecAttrAccount as String: normalizedKey.name 99 ] 100 } 101 102 func namespaceQuery(_ namespace: String) throws -> [String: Any] { 103 return [ 104 kSecClass as String: kSecClassGenericPassword, 105 kSecAttrService as String: try RadrootsSecureStoreKey.serviceName( 106 servicePrefix: servicePrefix, 107 namespace: namespace 108 ) 109 ] 110 } 111 112 func accessibilityConstant(for policy: RadrootsSecretAccessPolicy) -> CFString { 113 switch (policy.accessibility, policy.deviceLocalOnly) { 114 case (.whenUnlocked, true): 115 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 116 case (.whenUnlocked, false): 117 kSecAttrAccessibleWhenUnlocked 118 case (.afterFirstUnlock, true): 119 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 120 case (.afterFirstUnlock, false): 121 kSecAttrAccessibleAfterFirstUnlock 122 } 123 } 124 125 func keychainPolicyMapping(for policy: RadrootsSecretAccessPolicy) -> RadrootsKeychainSecretPolicyMapping { 126 RadrootsKeychainSecretPolicyMapping( 127 accessibilityConstant: accessibilityConstant(for: policy), 128 usesAccessControl: policy.userPresenceRequired, 129 accessControlFlags: policy.userPresenceRequired ? .userPresence : [] 130 ) 131 } 132 133 func accessControl(for policy: RadrootsSecretAccessPolicy) throws -> SecAccessControl { 134 try accessControl(for: keychainPolicyMapping(for: policy)) 135 } 136 137 func accessControl(for mapping: RadrootsKeychainSecretPolicyMapping) throws -> SecAccessControl { 138 try accessControlFactory(mapping) 139 } 140 141 private func mutationAttributes( 142 _ value: Data, 143 policy: RadrootsSecretAccessPolicy 144 ) throws -> [String: Any] { 145 let mapping = keychainPolicyMapping(for: policy) 146 var attributes: [String: Any] = [ 147 kSecValueData as String: value 148 ] 149 if mapping.usesAccessControl { 150 attributes[kSecAttrAccessControl as String] = try accessControl(for: mapping) 151 } else { 152 attributes[kSecAttrAccessible as String] = mapping.accessibilityConstant 153 } 154 return attributes 155 } 156 157 private static func makeAccessControl(for mapping: RadrootsKeychainSecretPolicyMapping) throws -> SecAccessControl { 158 var error: Unmanaged<CFError>? 159 guard let accessControl = SecAccessControlCreateWithFlags( 160 nil, 161 mapping.accessibilityConstant, 162 mapping.accessControlFlags, 163 &error 164 ) else { 165 let message = (error?.takeRetainedValue() as Error?)?.localizedDescription 166 ?? "keychain access control initialization failed" 167 throw RadrootsAppleSecurityError.invalidRequest(message) 168 } 169 return accessControl 170 } 171 172 static func mapStatus(_ status: OSStatus, defaultMessage: String) -> RadrootsAppleSecurityError { 173 switch status { 174 case errSecItemNotFound: 175 .notFound(defaultMessage) 176 case errSecAuthFailed: 177 .permissionDenied(defaultMessage) 178 case errSecInteractionNotAllowed: 179 .transientFailure(defaultMessage) 180 case errSecUserCanceled: 181 .userCancelled(defaultMessage) 182 case errSecNotAvailable: 183 .unavailable(defaultMessage) 184 default: 185 .keychainStatus(status, defaultMessage) 186 } 187 } 188 } 189 190 struct RadrootsKeychainSecretPolicyMapping: Equatable { 191 let accessibilityConstant: CFString 192 let usesAccessControl: Bool 193 let accessControlFlags: SecAccessControlCreateFlags 194 195 static func == ( 196 lhs: RadrootsKeychainSecretPolicyMapping, 197 rhs: RadrootsKeychainSecretPolicyMapping 198 ) -> Bool { 199 String(lhs.accessibilityConstant) == String(rhs.accessibilityConstant) 200 && lhs.usesAccessControl == rhs.usesAccessControl 201 && lhs.accessControlFlags == rhs.accessControlFlags 202 } 203 }