I am storing passwords into the iOS keychain and later retrieving them to implement a \"remember me\" (auto-login) feature on my app.
I implemented my own wrapper aroun
Due to enhanced security requirements from above, I changed the access attribute from kSecAttrAccessibleWhenUnlocked to kSecAttrAccessibleWhenUnlockedThisDeviceOnly (i.e., prevent the password from being copied during device backups).
...And now my code is broken again! This isn't an issue of trying to read the password stored with the attribute set to kSecAttrAccessibleWhenUnlocked using a dictionary that contains kSecAttrAccessibleWhenUnlockedThisDeviceOnly instead, no; I deleted the app and started from scratch, and it still fails.
I have posted a new question (with a link back to this one).
Thanks to the suggestion by @Edvinas in his answer above, I was able to figure out what was wrong.
As he suggests, I downloaded the Keychain wrapper class used in this Github repository (Project 28), and replaced my code with calls to the main class, and lo and behold - it did work.
Next, I added console logs to compare the query dictionaries used in the Keychain wrapper for storing/retrieving the password (i.e., the arguments to SecItemAdd() and SecItemCopyMatching) against the ones I was using. There were several differences:
[String, Any]), and my code uses NSDictionary (I must update this. It's 2019 already!).kSecAttrService, I was using CFBundleName. This shouldn't be an issue, but my bundle name contains Japanese characters...CFBoolean values for kSecReturnData, I was using Swift booleans.kSecAttrGeneric in addition to kSecAttrAccount and kSecAttrService, my code only uses the latter two.kSecAttrGeneric and kSecAttrAccount as Data, my code was storing the values directly as String.kSecAttrAccessControl and kSecUseAuthenticationUI, the wrapper doesn't (it uses kSecAttrAccessible with configurable values. In my case, I believe kSecAttrAccessibleWhenUnlocked applies).kSecUseOperationPrompt, the wrapper doesn'tkSecMatchLimit to the value kSecMatchLimitOne, my code doesn't.(Points 6 and 7 are not really necessary, because although I first designed my class with biometric authentication in mind, I am not using it currently.)
...etc.
I matched my dictionaries to those of the wrapper and finally got the copy query to succeed. Then, I removed the differing items until I could pinpoint the cause. It turns out that:
kSecAttrGeneric (just kSecAttrService and kSecAttrAccount, as mentioned in @Edvinas's answer).kSecAttrAccount (it may be a good idea, but in my case, it would break previously stored data and complicate migration).kSecMatchLimit isn't needed either (perhaps because my code results in a unique value stored/matched?), but I guess I will add it just to be safe (doesn't feel like it would break backward compatibility).kSecReturnData work fine. Assigning the integer 1 breaks it though (although that's how the value is logged on the console).kSecService is ok too....etc.
So in the end, I:
kSecUseAuthenticationUI from the insert dictionary and replaced it with kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked.kSecUseAuthenticationUI from the insert dictionary.kSecUseOperationPrompt from the copy dictionary....and now my code works. I will have to test whether this load passwords stored using the old code on actual devices (otherwise, my users will lose their saved passwords on the next update).
So this is my final, working code:
import Foundation
import Security
/**
Provides keychain-based support for secure, local storage and retrieval of the
user's password.
*/
class LocalCredentialStore {
private static let serviceName: String = {
guard let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String else {
return "Unknown App"
}
return name
}()
private static let accountName = "Login Password"
/**
Returns `true` if successfully deleted, or no password was stored to begin
with; In case of anomalous result `false` is returned.
*/
@discardableResult static func deleteStoredPassword() -> Bool {
let deleteQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
kSecAttrService: serviceName,
kSecAttrAccount: accountName,
kSecReturnData: false
]
let result = SecItemDelete(deleteQuery as CFDictionary)
switch result {
case errSecSuccess, errSecItemNotFound:
return true
default:
return false
}
}
/**
If a password is already stored, it is silently overwritten.
*/
static func storePassword(_ password: String, protectWithPasscode: Bool, completion: (() -> Void)? = nil, failure: ((Error) -> Void)? = nil) {
// Encode payload:
guard let dataToStore = password.data(using: .utf8) else {
failure?(NSError(localizedDescription: ""))
return
}
// DELETE any previous entry:
self.deleteStoredPassword()
// INSERT new value:
let insertQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
kSecValueData: dataToStore,
kSecAttrService: serviceName, // These two values identify the entry;
kSecAttrAccount: accountName // together they become the primary key in the Database.
]
let resultCode = SecItemAdd(insertQuery as CFDictionary, nil)
guard resultCode == errSecSuccess else {
failure?(NSError(localizedDescription: ""))
return
}
completion?()
}
/**
If a password is stored and can be retrieved successfully, it is passed back as the argument of
`completion`; otherwise, `nil` is passed.
Completion handler is always executed on themain thread.
*/
static func loadPassword(completion: @escaping ((String?) -> Void)) {
// [1] Perform search on background thread:
DispatchQueue.global().async {
let selectQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
kSecAttrService: serviceName,
kSecAttrAccount: accountName,
kSecMatchLimit: kSecMatchLimitOne,
kSecReturnData: true
]
var extractedData: CFTypeRef?
let result = SecItemCopyMatching(selectQuery, &extractedData)
// [2] Rendez-vous with the caller on the main thread:
DispatchQueue.main.async {
switch result {
case errSecSuccess:
guard let data = extractedData as? Data, let password = String(data: data, encoding: .utf8) else {
return completion(nil)
}
completion(password)
case errSecUserCanceled:
completion(nil)
case errSecAuthFailed:
completion(nil)
case errSecItemNotFound:
completion(nil)
default:
completion(nil)
}
}
}
}
}
Final Words Of Wisdom: Unless you have a strong reason not to, just grab the Keychain Wrapper that @Edvinas mentioned in his answer (this repository, project 28)) and move on!