apple_kit

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

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 }