Skip to content

Instantly share code, notes, and snippets.

@kmarcell
Created April 15, 2024 08:54
Show Gist options
  • Save kmarcell/fe372f350fc1d57d04fa1657d3f3714b to your computer and use it in GitHub Desktop.
Save kmarcell/fe372f350fc1d57d04fa1657d3f3714b to your computer and use it in GitHub Desktop.
SecurelyStored a Swift property wrapper for Codable items stored in Keychain
import Foundation
public class ConcernedKeychainAccessor: KeychainAccessor {
override public func storeData<T: Codable>(_ object: T, forKey key: String) -> OSStatus {
let status = super.storeData(object, forKey: key)
if status != errSecSuccess {
NotificationCenter.default.post(name: NSNotification.Name("KeychainAccessorError"), object: nil)
}
return status
}
override public func deleteData(forKey key: String) -> OSStatus {
let status = super.deleteData(forKey: key)
if status != errSecSuccess {
NotificationCenter.default.post(name: NSNotification.Name("KeychainAccessorError"), object: nil)
}
return status
}
}
import Foundation
import Security
public class KeychainAccessor {
private let service: String
private let add: (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
private let delete: (CFDictionary) -> OSStatus
private let copy: (CFDictionary, UnsafeMutablePointer<AnyObject?>?) -> OSStatus
public convenience init(service: String) {
self.init(service: service,
add: SecItemAdd,
delete: SecItemDelete,
copy: SecItemCopyMatching)
}
public init(service: String,
add: @escaping (CFDictionary, UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus,
delete: @escaping (CFDictionary) -> OSStatus,
copy: @escaping (CFDictionary, UnsafeMutablePointer<AnyObject?>?) -> OSStatus) {
self.service = service
self.add = add
self.delete = delete
self.copy = copy
}
public func storeData<T: Codable>(_ object: T, forKey key: String) -> OSStatus {
let encoder = JSONEncoder()
guard let encoded = try? encoder.encode(object) else {
return errSecParam
}
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: encoded]
_ = delete(query as CFDictionary)
return add(query as CFDictionary, nil)
}
public func deleteData(forKey key: String) -> OSStatus {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key]
return delete(query as CFDictionary)
}
public func retrieveData<T: Codable>(forKey key: String) -> T? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne]
var dataTypeRef: AnyObject?
let status: OSStatus = copy(query as CFDictionary, &dataTypeRef)
if status == noErr {
let data = dataTypeRef as! Data
let decoder = JSONDecoder()
return try? decoder.decode(T.self, from: data)
} else {
return nil
}
}
}
import Foundation
@propertyWrapper
public struct SecurelyStored<T: Codable> {
private let key: String
private let keychainAccessor: KeychainAccessor
public init(key: String, service: String) {
self.init(key: key, accessor: KeychainAccessor(service: service))
}
public init(key: String, accessor: KeychainAccessor) {
self.key = key
self.keychainAccessor = accessor
}
public var wrappedValue: T? {
get {
keychainAccessor.retrieveData(forKey: key)
}
set {
guard let value = newValue else {
_ = keychainAccessor.deleteData(forKey: key)
return
}
_ = keychainAccessor.storeData(value, forKey: key)
}
}
}
import XCTest
import Foundation
import Inspired
final class SecurelyStoredTests: XCTestCase {
/// In order to check if the Apple Keychain is accessible
/// and we have read and write permissions on the platform where we run these tests,
/// this test creates a String which is stored securely in the Keychain
/// than the data is retrieved and asserted.
func testKeychainIsAccessible() {
let accessor = KeychainAccessor(service: "TestService")
let exampleKey = "MyTestKey"
let exampleData = "Some test data"
let storeResult = accessor.storeData(exampleData, forKey: exampleKey)
XCTAssertEqual(storeResult, errSecSuccess, "Failed to store data in Keychain")
let value: String? = accessor.retrieveData(forKey: exampleKey)
XCTAssertEqual(exampleData, value)
let deleteResult = accessor.deleteData(forKey: exampleKey)
XCTAssertEqual(deleteResult, errSecSuccess, "Failed to delete data from Keychain")
}
func testKeychainAccessorCanStoreElsewhere() {
var values = [String: Any]()
let accessor = KeychainAccessor(
service: "TestService",
add: { query, _ in
let service = (query as Dictionary)[kSecAttrService] as! String
let key = (query as Dictionary)[kSecAttrAccount] as! String
let data = (query as Dictionary)[kSecValueData]
values[service+key] = data
return errSecSuccess
},
delete: { query in
let service = (query as Dictionary)[kSecAttrService] as! String
let key = (query as Dictionary)[kSecAttrAccount] as! String
values[service+key] = nil
return errSecSuccess
},
copy: { query, dataTypeRef in
let service = (query as Dictionary)[kSecAttrService] as! String
let key = (query as Dictionary)[kSecAttrAccount] as! String
if let value = values[service+key] as? AnyObject {
dataTypeRef?.pointee = value
return errSecSuccess
} else {
return errSecItemNotFound
}
})
let exampleKey = "MyTestKey"
let exampleData = "Some test data"
let storeResult = accessor.storeData(exampleData, forKey: exampleKey)
XCTAssertEqual(storeResult, errSecSuccess, "Failed to store data in Keychain")
let value: String? = accessor.retrieveData(forKey: exampleKey)
XCTAssertEqual(exampleData, value)
let deleteResult = accessor.deleteData(forKey: exampleKey)
XCTAssertEqual(deleteResult, errSecSuccess, "Failed to delete data from Keychain")
}
func testSecurelyStoredAPIToken() {
class MyViewModel {
static let keychainService = "MyViewModelService"
@SecurelyStored(key: "ApiToken", service: MyViewModel.keychainService)
var apiToken: String?
}
let testee = MyViewModel()
let exampleData = "Some test data"
testee.apiToken = exampleData
XCTAssertEqual(testee.apiToken, exampleData, "Failed to store data in Keychain")
let accessor = KeychainAccessor(service: MyViewModel.keychainService)
XCTAssertEqual(accessor.retrieveData(forKey: "ApiToken"), exampleData)
testee.apiToken = nil
XCTAssertEqual(testee.apiToken, nil, "Failed to delete data from Keychain")
}
func testSecurelyStoredAPITokenWithErrorHandling() {
class MyViewModel {
static let keychainService = "MyViewModelService"
@SecurelyStored(key: "ApiToken", accessor: ConcernedKeychainAccessor(service: MyViewModel.keychainService))
var apiToken: String?
}
let expectation = XCTNSNotificationExpectation(name: Notification.Name("KeychainAccessorError"))
let testee = MyViewModel()
// Whoops, we're trying to delete something that doesn't exist,
// we should get `errSecItemNotFound`
testee.apiToken = nil
wait(for: [expectation], timeout: 1.0)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment