Skip to content

Instantly share code, notes, and snippets.

@thomsmed
Last active September 4, 2023 18:49
Show Gist options
  • Select an option

  • Save thomsmed/f486dabc221ad4a079947e78444a2b1c to your computer and use it in GitHub Desktop.

Select an option

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).
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