apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

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 }