Created
April 15, 2024 08:54
-
-
Save kmarcell/fe372f350fc1d57d04fa1657d3f3714b to your computer and use it in GitHub Desktop.
SecurelyStored a Swift property wrapper for Codable items stored in Keychain
This file contains 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 | |
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 | |
} | |
} |
This file contains 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 | |
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 | |
} | |
} | |
} |
This file contains 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 | |
@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) | |
} | |
} | |
} |
This file contains 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 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