-
-
Save pauljohanneskraft/4652fbeae67a2206ad6b4296675e9bb5 to your computer and use it in GitHub Desktop.
struct KeychainItem { | |
// MARK: Nested Types | |
enum KeychainError: Error { | |
case noPassword | |
case unexpectedPasswordData | |
case unexpectedItemData | |
case unhandledError(status: OSStatus) | |
} | |
// MARK: Stored Properties | |
let service: String | |
let account: String | |
let accessGroup: String? | |
// MARK: Initialization | |
init(service: String, account: String, accessGroup: String? = nil) { | |
self.service = service | |
self.account = account | |
self.accessGroup = accessGroup | |
} | |
// MARK: Methods | |
func get() -> String? { | |
/* | |
Build a query to find the item that matches the service, account and | |
access group. | |
*/ | |
var query = KeychainItem.query(service: service, account: account, accessGroup: accessGroup) | |
query[kSecMatchLimit as String] = kSecMatchLimitOne | |
query[kSecReturnAttributes as String] = kCFBooleanTrue | |
query[kSecReturnData as String] = kCFBooleanTrue | |
// Try to fetch the existing keychain item that matches the query. | |
var queryResult: AnyObject? | |
let status = withUnsafeMutablePointer(to: &queryResult) { | |
SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) | |
} | |
// Check the return status and throw an error if appropriate. | |
guard status != errSecItemNotFound else { return nil } | |
guard status == noErr else { return nil } | |
// Parse the password string from the query result. | |
guard let existingItem = queryResult as? [String: AnyObject], | |
let passwordData = existingItem[kSecValueData as String] as? Data, | |
let password = String(data: passwordData, encoding: String.Encoding.utf8) | |
else { | |
return nil | |
} | |
return password | |
} | |
func set(_ value: String?) { | |
if let value = value { | |
save(value) | |
} else { | |
delete() | |
} | |
} | |
func save(_ password: String) { | |
// Encode the password into an Data object. | |
let encodedPassword = Data(password.utf8) | |
if get() != nil { | |
var attributesToUpdate = [String: AnyObject]() | |
attributesToUpdate[kSecValueData as String] = encodedPassword as AnyObject? | |
let query = KeychainItem.query(service: service, | |
account: account, | |
accessGroup: accessGroup) | |
_ = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) | |
} else { | |
var newItem = KeychainItem.query(service: service, | |
account: account, | |
accessGroup: accessGroup) | |
newItem[kSecValueData as String] = encodedPassword as AnyObject? | |
_ = SecItemAdd(newItem as CFDictionary, nil) | |
} | |
} | |
func delete() { | |
// Delete the existing item from the keychain. | |
let query = KeychainItem.query(service: service, account: account, accessGroup: accessGroup) | |
_ = SecItemDelete(query as CFDictionary) | |
} | |
// MARK: Helpers | |
private static func items(forService service: String, accessGroup: String? = nil) throws -> [KeychainItem] { | |
// Build a query for all items that match the service and access group. | |
var query = KeychainItem.query(service: service, accessGroup: accessGroup) | |
query[kSecMatchLimit as String] = kSecMatchLimitAll | |
query[kSecReturnAttributes as String] = kCFBooleanTrue | |
query[kSecReturnData as String] = kCFBooleanFalse | |
// Fetch matching items from the keychain. | |
var queryResult: AnyObject? | |
let status = withUnsafeMutablePointer(to: &queryResult) { | |
SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) | |
} | |
// If no items were found, return an empty array. | |
guard status != errSecItemNotFound else { return [] } | |
// Throw an error if an unexpected status was returned. | |
guard status == noErr else { throw KeychainError.unhandledError(status: status) } | |
// Cast the query result to an array of dictionaries. | |
guard let resultData = queryResult as? [[String: AnyObject]] else { throw KeychainError.unexpectedItemData } | |
// Create a `KeychainItem` for each dictionary in the query result. | |
var items = [KeychainItem]() | |
for result in resultData { | |
guard let account = result[kSecAttrAccount as String] as? String else { | |
continue | |
} | |
let item = KeychainItem(service: service, account: account, accessGroup: accessGroup) | |
items.append(item) | |
} | |
return items | |
} | |
private static func query(service: String, | |
account: String? = nil, | |
accessGroup: String? = nil) -> [String: AnyObject] { | |
var query = [String: AnyObject]() | |
query[kSecClass as String] = kSecClassGenericPassword | |
query[kSecAttrService as String] = service as AnyObject? | |
if let account = account { | |
query[kSecAttrAccount as String] = account as AnyObject? | |
} | |
if let accessGroup = accessGroup { | |
query[kSecAttrAccessGroup as String] = accessGroup as AnyObject? | |
} | |
return query | |
} | |
} |
It actually does !!
Thank you so much for your help & fast reply.
I would ❤️ to have a version that enables saving of custom codable types. Maybe if you have time someday for an updated blog post...
Thank you for sharing your knowledge to help others progress.
You're welcome - glad that it worked!
The following encodes the Codable element into a JSON, which is then base64encoded into a string, which then again uses KeychainPasswordItem.
I'm not too sure whether the following would work, since it is just a quick idea and implementation (completely untested), but possibly the following could work for these cases.
@propertyWrapper
struct CodableSecureAppStorage<C: Codable> {
var item: KeychainItem
var encoder = JSONEncoder()
var decoder = JSONDecoder()
init(_ account: String, service: String = Bundle.main.bundleIdentifier!) {
self.item = .init(service: service, account: account)
}
public var wrappedValue: C? {
get {
item.get()
.flatMap { Data(base64Encoded: $0) }
.flatMap { try? decoder.decode(C.self, from: $0) }
}
nonmutating set {
let string = newValue
.flatMap { try? encoder.encode($0) }
.flatMap { $0.base64EncodedString() }
item.set(string)
}
}
}
Edit 1: Fix typo
Edit 2: Increase readability
Unfortunately it does not work, XCode complains with the following error "type of expression is ambiguous without more context" on the line
return try? decoder.decode(C.self, from: data)
ah, I checked it in Xcode and there was a small typo, since it is called Data(base64Encoded:)
instead of Data(base64encoded:)
. Fixed it in the version above.
I had fixed that as well but still get the error...
For me it does compile, so can you please check, if you have really copied the whole snippet? For example, the type of the wrappedValue
property is C?
instead of String?
now. If I change it back to String?
, I get the same compiler error.
Right. I had left it to String?
Thank you so much for your help & responsiveness. You rock!
Perfect - you're welcome! 😊 I have also just updated the code snippet with some increased readability.
I am working on a music player at the moment. You will be the first to get a free copy once it is available!
I wish property wrappers could conform to Observable Object. It would make it much easier to implement in SwiftUI
@akillamac Great, thanks - looking forward to it!
Have a look at the DynamicProperty
protocol, that might just be what you need.
@akillamac Ah, sorry, must have missed that - Does it work when you change the setter to be "nonmutating"? i.e.: