升级到 iOS 13 后,钥匙串查询总是 Returns errSecItemNotFound

Keychain Query Always Returns errSecItemNotFound After Upgrading to iOS 13

我将密码存储到 iOS 钥匙串中,稍后检索它们以在我的应用程序上实现 "remember me"(自动登录)功能。

我围绕 Security.framework 函数(SecItemCopyMatching() 等)实现了我自己的包装器,并且在 iOS 12 之前它一直运行良好。

现在我正在测试我的应用程序不会因即将推出的 iOS 13 而中断,瞧瞧:

SecItemCopyMatching()总是returns.errSecItemNotFound

...即使我之前已经存储了我正在查询的数据。

我的包装器是一个具有静态属性的 class,以便在组装查询字典时方便地提供 kSecAttrServicekSecAttrAccount 的值:

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" 

// ...

我正在将密码插入钥匙串,代码如下:

/* 
  - 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?()
}

...稍后,我正在 检索 密码:

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)
            }
        }
    }
}

(我认为我用于这两个调用的词典中的任何条目都没有不合适的值...但也许我遗漏了 "get a pass" 刚刚发生的事情直到现在)

我已经设置 a repository 一个工作项目 (Xcode 11 beta) 来演示这个问题。

密码存储总是成功的;密码加载:


更新:我无法在设备上重现该问题,只能在模拟器上重现。在设备上,已成功检索存储的密码。 也许这是 iOS 13 Simulator and/or iOS x86 平台的 13 SDK 的错误或限制。

更新 2: 如果有人想出一种替代方法以某种方式解决这个问题(无论是通过设计还是利用 Apple 的一些监督),我会接受它作为一个答案。

我遇到过类似的问题,在任何与钥匙串相关的操作中我都得到了 errSecItemNotFound,但在模拟器上只有 。在真实设备上它是完美的,我已经在不同的模拟器上使用最新的 Xcode(测试版、GM、稳定版)进行了测试,给我带来困难的是 iOS 13 个。

问题是我在查询属性 kSecClass 中使用 kSecClassKey,但没有 'required' 值(查看 类 与哪些值 here) 用于生成主键:

  • kSecAttrApplicationLabel
  • kSecAttrApplicationTag
  • kSecAttrKeyType
  • kSecAttrKeySizeInBits
  • kSecAttrEffectiveKeySize

而有用的是为 kSecClass 选择 kSecClassGenericPassword 提供 'required' 值来生成主键:

  • kSecAttrAccount
  • kSecAttrService

请参阅 here 了解有关 kSecClass 类型的更多信息以及它们应具有的其他属性。

我通过启动一个新的 iOS 13 项目并复制我们应用程序中使用的钥匙串包装器得出了这个结论,正如预期的那样不起作用,所以我找到了这个可爱的使用指南keychain here 并试用了他们的包装器 毫不奇怪 有效,然后逐行比较我的实现与他们的实现。

此问题已在雷达中报告:http://openradar.appspot.com/7251207

希望这对您有所帮助。

更新

由于上述增强的安全要求,我将访问属性从 kSecAttrAccessibleWhenUnlocked 更改为 kSecAttrAccessibleWhenUnlockedThisDeviceOnly(即防止在设备备份期间复制密码)。

...现在我的代码又被破坏了! 这不是尝试读取使用 kSecAttrAccessibleWhenUnlocked 属性设置存储的密​​码的问题一个包含 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 的字典,不;我删除了应用程序并从头开始,但它仍然失败。

我已经发布了 (有一个 link 回到这个)。


原答案:

感谢@Edvinas 在 中的建议,我能够找出问题所在。

按照他的建议,我下载了 this Github repository(项目 28)中使用的钥匙串包装器 class,并将我的代码替换为对主 class 的调用,瞧瞧- 它确实有效

接下来, 我添加了控制台日志来比较 查询字典 在钥匙串包装器中用于 storing/retrieving 密码(即 SecItemAdd()SecItemCopyMatching) 的论点反对我使用的那些。有几点不同:

  1. 包装器使用Swift字典([String, Any]),我的代码使用NSDictionary(我必须更新这个。已经2019年了!)。
  2. 包装器使用 捆绑包标识符 作为 kSecAttrService 的值,我使用的是 CFBundleName。这应该不是问题,但我的包名称包含日语字符...
  3. 包装器对 kSecReturnData 使用 CFBoolean 值,我使用的是 Swift 布尔值。
  4. wrapper除了kSecAttrAccountkSecAttrService外还用了kSecAttrGeneric,我的代码只用了后两个
  5. 包装器将 kSecAttrGenerickSecAttrAccount 的值编码为 Data,我的代码直接将值存储为 String.
  6. 我的插入字典使用 kSecAttrAccessControlkSecUseAuthenticationUI,包装器不使用(它使用具有可配置值的 kSecAttrAccessible。在我的例子中,我相信 kSecAttrAccessibleWhenUnlocked 适用).
  7. 我的检索字典使用 kSecUseOperationPrompt,包装器不使用
  8. 包装器将 kSecMatchLimit 指定为值 kSecMatchLimitOne,我的代码没有。

(第 6 点和第 7 点并不是真正必要的,因为 虽然我最初设计 class 时考虑了生物识别身份验证,但我目前没有使用它。

...等等

我将我的词典与包装器的词典进行了匹配,最终使复制查询成功。然后,我删除了不同的项目,直到我能查明原因。结果是:

  1. 我不需要 kSecAttrGeneric(只需要 kSecAttrServicekSecAttrAccount,如@Edvinas 的回答所述)。
  2. 我不需要对 kSecAttrAccount 的值进行数据编码(这可能是个好主意,但在我的情况下,它会破坏以前存储的数据并使迁移复杂化)。
  3. 原来也不需要 kSecMatchLimit(也许是因为我的代码产生了一个唯一值 stored/matched?),但我想我会添加它只是为了安全起见(不是'感觉它不会破坏向后兼容性)。
  4. Swift 布尔值,例如kSecReturnData 工作正常。分配 整数 1 会破坏它(尽管这是在控制台上记录值的方式)。
  5. 作为 kSecService 值的(日文)包名称也可以。

...等等

所以最后,我:

  1. 从插入字典中删除了 kSecUseAuthenticationUI 并将其替换为 kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
  2. 已从插入字典中删除 kSecUseAuthenticationUI
  3. 已从复制字典中删除 kSecUseOperationPrompt

...现在我的代码可以工作了。我将不得不测试是否在实际设备上使用旧代码存储此加载密码(否则,我的用户将丢失他们保存的密码在下次更新时)。

所以这是我最后的工作代码:

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)
                }
            }
        }
    }
}

最后的智慧:除非你有充分的理由,否则就抓住@Edvinas在他的文章中提到的Keychain Wrapper回答(this repository,项目 28))并继续前进!

我们在生成密钥对时遇到了同样的问题 - 在设备上工作得很好,但在模拟器 iOS 13 和更高版本上,当我们稍后尝试检索它时它找不到密钥。

Apple 文档中的解决方案: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.

简而言之,在使用 SecKeyCreateRandomKey 创建密钥后,您需要使用 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!

关于 kSecClassGenericPassword 中的问题,我试图了解问题所在,并找到了解决方案。

基本上,Apple 似乎正在解决 kSecAttrAccessControl 的问题,因此在 iOS 版本 13 以下,您可以添加没有生物识别身份的 kSecAttrAccessControl 和更高版本 iOS 的 keyChain 对象13 在模拟器中不再工作。

因此,解决方案是当您想要使用生物识别加密 keyChain 对象时,您需要将 kSecAttrAccessControl 添加到您的查询中,但如果您不需要通过生物识别加密,则只需添加 kSecAttrAccessible 这才是正确的做法。

例子

查询生物特征加密:

guard let accessControl = SecAccessControlCreateWithFlags(kCFAllocatorDefault,
                                                          kSecAttrAccessibleWhenUnlocked,
                                                          userPresence,
                                                          nil) else {
                                                              // failed to create accessControl
                                                              return 
                                                          }


var attributes: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                           kSecAttrService: "Your service",
                                           kSecAttrAccount: "Your account",
                                           kSecValueData: "data",
                                           kSecAttrAccessControl: accessControl]

查询常规钥匙串(无生物识别):

var attributes: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                               kSecAttrService: "Your service",
                                               kSecAttrAccount: "Your account",
                                               kSecValueData: "data",
                                               kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]

经过半天的实验,我发现使用 kSecClassGenericPassword 的一个非常基本的实例,我在模拟器和真实硬件上都遇到了问题。阅读文档后,我注意到 kSecAttrSynchronizable 有一个 kSecAttrSynchronizableAny。要接受任何其他属性的任何值,您只需不将其包含在查询中即可。这是一个线索。

我发现当我将 kSecAttrSynchronizable 设置为 kSecAttrSynchronizableAny 时,所有查询都有效。当然,如果我真的想过滤那个值,我也可以将它设置为 kCFBooleanTrue(或 *False)。

鉴于该属性,一切似乎都符合我的预期。希望这会为其他人节省半天的时间来处理测试代码。