问题
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 around the Security.framework
functions (SecItemCopyMatching()
, etc.), and it was working like a charm up until iOS 12.
Now I am testing that my app doesn't break with the upcoming iOS 13, and lo and behold:
SecItemCopyMatching()
always returns .errSecItemNotFound
...even though I have previously stored the data I am querying.
My wrapper is a class with static properties to conveniently provide the values of the kSecAttrService
and kSecAttrAccount
when assembling the query dictionaries:
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"
// ...
I am inserting the password into the keychain with code like the following:
/*
- NOTE: protectWithPasscode is currently always FALSE, so the password
can later be retrieved programmatically, i.e. without user interaction.
*/
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 protection: CFTypeRef = protectWithPasscode ? kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly : kSecAttrAccessibleWhenUnlocked
let flags: SecAccessControlCreateFlags = protectWithPasscode ? .userPresence : []
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
protection,
flags,
nil) else {
failure?(NSError(localizedDescription: ""))
return
}
let insertQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccessControl: accessControl,
kSecValueData: dataToStore,
kSecUseAuthenticationUI: kSecUseAuthenticationUIAllow,
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?()
}
...and later, I am retrieving the password with:
static func loadPassword(completion: @escaping ((String?) -> Void)) {
// [1] Perform search on background thread:
DispatchQueue.global().async {
let selectQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: serviceName,
kSecAttrAccount: accountName,
kSecReturnData: true,
kSecUseOperationPrompt: "Please authenticate"
]
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) // < SUCCESS
case errSecUserCanceled:
completion(nil)
case errSecAuthFailed:
completion(nil)
case errSecItemNotFound:
completion(nil)
default:
completion(nil)
}
}
}
}
(I don't think any of the entries of the dictionaries I use for either call has an inappropriate value... but perhaps I am missing something that just happened to "get a pass" until now)
I have set up a repository with a working project (Xcode 11 beta) that demonstrates the problem.
The password storing always succeeds; The password loading:
- Succeeds on Xcode 10 - iOS 12 (and earlier), but
- Fails with
.errSecItemNotFound
on Xcode 11 - iOS 13.
UPDATE: I can not reproduce the issue on the device, only Simulator. On the device, the stored password is retrieved successfully. Perhaps this is a bug or limitation on the iOS 13 Simulator and/or iOS 13 SDK for the x86 platform.
UPDATE 2: If someone comes up with an alternative approach that somehow works around the issue (whether by design or by taking advantage of some oversight by Apple), I will accept it as an answer.
回答1:
I've had a similar issue where I was getting errSecItemNotFound
with any Keychain-related action but only on a simulator. On real device it was perfect, I've tested with latest Xcodes (beta, GM, stable) on different simulators and the ones that were giving me a hard time were iOS 13 ones.
The problem was that I was using kSecClassKey
in query attribute kSecClass
, but without the 'required' values (see what classes go with which values here) for generating a primary key:
kSecAttrApplicationLabel
kSecAttrApplicationTag
kSecAttrKeyType
kSecAttrKeySizeInBits
kSecAttrEffectiveKeySize
And what helped was to pick kSecClassGenericPassword
for kSecClass
and provide the 'required' values for generating a primary key:
kSecAttrAccount
kSecAttrService
See here on more about kSecClass types and what other attributes should go with them.
I came to this conclusion by starting a new iOS 13 project and copying over the Keychain wrapper that was used in our app, as expected that did not work so I've found this lovely guide on using keychain here and tried out their wrapper which no surprise worked, and then went line by line comparing my implementation with theirs.
This issue already reported in radar: http://openradar.appspot.com/7251207
Hope this helps.
回答2:
Update
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).
Original Answer:
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:
- The wrapper uses Swift Dictionary (
[String, Any]
), and my code usesNSDictionary
(I must update this. It's 2019 already!). - The wrapper uses the bundle identifier for the value of
kSecAttrService
, I was usingCFBundleName
. This shouldn't be an issue, but my bundle name contains Japanese characters... - The wrapper uses
CFBoolean
values forkSecReturnData
, I was using Swift booleans. - The wrapper uses
kSecAttrGeneric
in addition tokSecAttrAccount
andkSecAttrService
, my code only uses the latter two. - The wrapper encodes the values of
kSecAttrGeneric
andkSecAttrAccount
asData
, my code was storing the values directly asString
. - My insert dictionary uses
kSecAttrAccessControl
andkSecUseAuthenticationUI
, the wrapper doesn't (it useskSecAttrAccessible
with configurable values. In my case, I believekSecAttrAccessibleWhenUnlocked
applies). - My retrieve dictionary uses
kSecUseOperationPrompt
, the wrapper doesn't - The wrapper specifies
kSecMatchLimit
to the valuekSecMatchLimitOne
, 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:
- I don't need
kSecAttrGeneric
(justkSecAttrService
andkSecAttrAccount
, as mentioned in @Edvinas's answer). - I don't need to data-encode the value of
kSecAttrAccount
(it may be a good idea, but in my case, it would break previously stored data and complicate migration). - It turns out
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). - Swift booleans for e.g.
kSecReturnData
work fine. Assigning the integer1
breaks it though (although that's how the value is logged on the console). - The (Japanese) bundle name as a value for
kSecService
is ok too.
...etc.
So in the end, I:
- Removed
kSecUseAuthenticationUI
from the insert dictionary and replaced it withkSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
. - Removed
kSecUseAuthenticationUI
from the insert dictionary. - Removed
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!
回答3:
We had the same issue when generating a key pair - works just fine on devices, but on simulator iOS 13 and above it cannot find the key when we try to retreive it later on.
The solution is in Apple documentation: https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/storing_keys_in_the_keychain
When you generate keys yourself, as described in Generating New Cryptographic Keys, you can store them in the keychain as an implicit part of that process. If you obtain a key by some other means, you can still store it in the keychain.
In short, after you create a key with SecKeyCreateRandomKey
, you need to save this key in the Keychain using SecItemAdd
:
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateRandomKey(createKeyQuery as CFDictionary, &error) else {
// An error occured.
return
}
let saveKeyQuery: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag,
kSecValueRef as String: key
]
let status = SecItemAdd(saveKeyQuery as CFDictionary, nil)
guard status == errSecSuccess else {
// An error occured.
return
}
// Success!
来源:https://stackoverflow.com/questions/56700680/keychain-query-always-returns-errsecitemnotfound-after-upgrading-to-ios-13