Last active
September 4, 2023 18:49
-
-
Save thomsmed/f486dabc221ad4a079947e78444a2b1c to your computer and use it in GitHub Desktop.
A general storage for data required to be backed by a secure storage (e.g the device's keychain).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| /// A general storage for data required to be backed by a secure storage. | |
| public protocol SecureDataStorage { | |
| func string(for key: String) throws -> String? | |
| func set(_ string: String, for key: String) throws | |
| func value<Value: Decodable>(for key: String) throws -> Value? | |
| func set<Value: Encodable>(_ value: Value, for key: String) throws | |
| func data(for key: String) throws -> Data? | |
| func set(_ data: Data, for key: String) throws | |
| func delete(for key: String) throws | |
| func deleteAll() throws | |
| func keys() throws -> [String] | |
| } | |
| /// An enumeration describing errors that might occur while interacting with ``SecureDataStorage``. | |
| public enum SecureDataStorageError: Error { | |
| case invalidKey | |
| case typeMismatch | |
| case failedToPersist | |
| case failedToDelete | |
| case unexpectedError(Int) | |
| } | |
| /// A general storage for storing data securely in the device's keychain. | |
| public struct KeychainSecureDataStorage { | |
| private static let jsonEncoder = JSONEncoder() | |
| private static let jsonDecoder = JSONDecoder() | |
| /// A namespace associated with the data managed by this instance of ``SecureDataStorage``. | |
| private let namespace: String | |
| public init(namespace: String) { | |
| self.namespace = namespace | |
| } | |
| /// Make a Keychain search query based on the given `key`. | |
| private func makeSearchQuery(for key: String) throws -> CFDictionary { | |
| guard let account = key.data(using: .utf8) else { | |
| throw SecureDataStorageError.invalidKey | |
| } | |
| return [ | |
| kSecClass: kSecClassGenericPassword, | |
| kSecAttrService: namespace, | |
| kSecAttrAccount: account | |
| ] as [CFString : Any] as CFDictionary | |
| } | |
| /// Make a Keychain copy query based on the given `key`. | |
| private func makeCopyQuery(for key: String) throws -> CFDictionary { | |
| guard let account = key.data(using: .utf8) else { | |
| throw SecureDataStorageError.invalidKey | |
| } | |
| return [ | |
| kSecClass: kSecClassGenericPassword, | |
| kSecAttrService: namespace, | |
| kSecAttrAccount: account, | |
| kSecMatchLimit: kSecMatchLimitOne, | |
| kSecReturnData: true | |
| ] as [CFString : Any] as CFDictionary | |
| } | |
| /// Make a Keychain add query based on the given `key` and `data`. | |
| /// ``kSecAttrAccessible`` = ``kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`` is necessary to make sure the keychain item is no accessible until after first device unlock (e.g first unlock after device restart), | |
| /// and will never leave this device (e.g restoring from backup on new device will not include this item). | |
| private func makeAddQuery(for key: String, data: Data) throws -> CFDictionary { | |
| guard let account = key.data(using: .utf8) else { | |
| throw SecureDataStorageError.invalidKey | |
| } | |
| return [ | |
| kSecClass: kSecClassGenericPassword, | |
| kSecAttrService: namespace, | |
| kSecAttrAccount: account, | |
| kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, | |
| kSecValueData: data | |
| ] as [CFString : Any] as CFDictionary | |
| } | |
| } | |
| extension KeychainSecureDataStorage: SecureDataStorage { | |
| public func string(for key: String) throws -> String? { | |
| guard let data = try data(for: key) else { | |
| return nil | |
| } | |
| return String(data: data, encoding: .utf8) | |
| } | |
| public func set(_ string: String, for key: String) throws { | |
| guard let data = string.data(using: .utf8) else { | |
| throw SecureDataStorageError.failedToPersist | |
| } | |
| try set(data, for: key) | |
| } | |
| public func value<Value: Decodable>(for key: String) throws -> Value? { | |
| guard let data = try data(for: key) else { | |
| return nil | |
| } | |
| return try Self.jsonDecoder.decode(Value.self, from: data) | |
| } | |
| public func set<Value: Encodable>(_ value: Value, for key: String) throws { | |
| let data = try Self.jsonEncoder.encode(value) | |
| try set(data, for: key) | |
| } | |
| public func data(for key: String) throws -> Data? { | |
| let copyQuery = try makeCopyQuery(for: key) | |
| var item: CFTypeRef? | |
| let status = SecItemCopyMatching(copyQuery, &item) | |
| switch status { | |
| case errSecSuccess: | |
| guard let data = item as? Data else { | |
| throw SecureDataStorageError.typeMismatch | |
| } | |
| return data | |
| case errSecItemNotFound: | |
| return nil | |
| default: | |
| throw SecureDataStorageError.unexpectedError(Int(status)) | |
| } | |
| } | |
| public func set(_ data: Data, for key: String) throws { | |
| let searchQuery = try makeSearchQuery(for: key) | |
| let attributes = [ | |
| kSecValueData: data | |
| ] as CFDictionary | |
| var status = SecItemUpdate(searchQuery, attributes) | |
| if status == errSecItemNotFound { | |
| let addQuery = try makeAddQuery(for: key, data: data) | |
| status = SecItemAdd(addQuery, nil) | |
| } | |
| guard status == errSecSuccess else { | |
| throw SecureDataStorageError.failedToPersist | |
| } | |
| } | |
| public func delete(for key: String) throws { | |
| let searchQuery = try makeSearchQuery(for: key) | |
| let status = SecItemDelete(searchQuery) | |
| guard status == errSecSuccess || status == errSecItemNotFound else { | |
| throw SecureDataStorageError.failedToDelete | |
| } | |
| } | |
| public func deleteAll() throws { | |
| let allItemsQuery = [ | |
| kSecClass: kSecClassGenericPassword, | |
| kSecAttrService: namespace, | |
| kSecMatchLimit: kSecMatchLimitAll | |
| ] as [CFString : Any] as CFDictionary | |
| let status = SecItemDelete(allItemsQuery) | |
| guard status == errSecSuccess || status == errSecItemNotFound else { | |
| throw SecureDataStorageError.failedToDelete | |
| } | |
| } | |
| public func keys() throws -> [String] { | |
| let attributesForAllItemsQuery = [ | |
| kSecClass: kSecClassGenericPassword, | |
| kSecAttrService: namespace, | |
| kSecMatchLimit: kSecMatchLimitAll, | |
| kSecReturnAttributes: true | |
| ] as [CFString : Any] as CFDictionary | |
| var items: CFTypeRef? | |
| let status = SecItemCopyMatching( | |
| attributesForAllItemsQuery, &items | |
| ) | |
| switch status { | |
| case errSecSuccess: | |
| guard let attributeDictionaries = items as? [[CFString: Any]] else { | |
| throw SecureDataStorageError.typeMismatch | |
| } | |
| return attributeDictionaries.compactMap { attributes in | |
| attributes[kSecAttrAccount] as? String | |
| } | |
| case errSecItemNotFound: | |
| return [] | |
| default: | |
| throw SecureDataStorageError.unexpectedError(Int(status)) | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment