Skip to content

Instantly share code, notes, and snippets.

@pauljohanneskraft
Last active January 20, 2023 14:05
Show Gist options
  • Save pauljohanneskraft/4652fbeae67a2206ad6b4296675e9bb5 to your computer and use it in GitHub Desktop.
Save pauljohanneskraft/4652fbeae67a2206ad6b4296675e9bb5 to your computer and use it in GitHub Desktop.
KeychainItem
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
}
}
@akillamac
Copy link

Hi

I tried the full implementation from your post as follows but when I try to use it like so

@SecureAppStorage("test")
 var test: String?

... and later

test = "value" 

I get the following error : "cannot assign to property: self is immutable"

Below the implementation I got from your blog post found here : https://quickbirdstudios.com/blog/swift-property-wrappers/

@propertyWrapper
struct SecureAppStorage {
    
    var item: KeychainItem
    
    init(_ account: String, service: String = Bundle.main.bundleIdentifier!) {
        self.item = .init(service: service, account: account)
    }
    
    public var wrappedValue: String? {
        get {
            item.get()
        }
        set {
            item.set(newValue)
        }
    }
}

extension SecureAppStorage {
    var projectedValue: KeychainItem {
        item
    }
}

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
    }

}

Thanks for your help

@pauljohanneskraft
Copy link
Author

@akillamac Ah, sorry, must have missed that - Does it work when you change the setter to be "nonmutating"? i.e.:

@propertyWrapper
struct SecureAppStorage {
    
    var item: KeychainItem
    
    init(_ account: String, service: String = Bundle.main.bundleIdentifier!) {
        self.item = .init(service: service, account: account)
    }
    
    public var wrappedValue: String? {
        get {
            item.get()
        }
        nonmutating set {
            item.set(newValue)
        }
    }
}

@akillamac
Copy link

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.

@pauljohanneskraft
Copy link
Author

pauljohanneskraft commented Nov 25, 2020

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

@akillamac
Copy link

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)

@pauljohanneskraft
Copy link
Author

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.

@akillamac
Copy link

I had fixed that as well but still get the error...

@pauljohanneskraft
Copy link
Author

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.

@akillamac
Copy link

Right. I had left it to String?

Thank you so much for your help & responsiveness. You rock!

@pauljohanneskraft
Copy link
Author

Perfect - you're welcome! 😊 I have also just updated the code snippet with some increased readability.

@akillamac
Copy link

I am working on a music player at the moment. You will be the first to get a free copy once it is available!

@akillamac
Copy link

I wish property wrappers could conform to Observable Object. It would make it much easier to implement in SwiftUI

@pauljohanneskraft
Copy link
Author

@akillamac Great, thanks - looking forward to it!
Have a look at the DynamicProperty protocol, that might just be what you need.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment