33

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

我围绕Security.framework函数(SecItemCopyMatching()等)实现了我自己的包装器,直到 iOS 12 之前它都像一个魅力一样工作。

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

SecItemCopyMatching()总是返回.errSecItemNotFound

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

我的包装器是一个具有静态属性的类,可以在组装查询字典时方便地提供 和 的值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)
            }
        }
    }
}

(我不认为我用于任何一次调用的任何字典条目都有不适当的值......但也许我错过了一些刚刚发生的“通过”直到现在)

我已经建立了一个存储库,其中包含一个演示问题的工作项目(Xcode 11 beta)。

密码存储始终成功;密码加载:

  • 在 Xcode 10 - iOS 12(及更早版本)上成功,但
  • .errSecItemNotFound在 Xcode 11 - iOS 13 上失败

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

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

4

5 回答 5

6

我遇到了类似的问题,我遇到errSecItemNotFound了任何与钥匙串相关的操作,但在模拟器上。在真实设备上它是完美的,我已经在不同的模拟器上使用最新的 Xcode(beta、GM、stable)进行了测试,而让我感到困难的是 iOS 13 的。

问题是我kSecClassKey在查询属性中使用kSecClass,但没有“必需”值(在此处查看哪些类与哪些值一起使用)来生成主键:

  • kSecAttrApplicationLabel
  • kSecAttrApplicationTag
  • kSecAttrKeyType
  • kSecAttrKeySizeInBits
  • kSecAttrEffectiveKeySize

帮助选择kSecClassGenericPasswordkSecClass 提供生成主键的“必需”值:

  • kSecAttrAccount
  • kSecAttrService

请参阅此处了解有关 kSecClass 类型的更多信息以及它们应该附带的其他属性。

我通过启动一个新的 iOS 13 项目并复制在我们的应用程序中使用的 Keychain 包装器得出了这个结论,正如预期的那样,它不起作用所以我在这里找到了这个关于使用钥匙串的可爱指南并尝试了他们的包装器,没有惊喜工作,然后逐行比较我的实现与他们的实现。

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

希望这可以帮助。

于 2019-10-04T09:25:43.200 回答
3

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

我发现当我将 kSecAttrSynchronizable 设置为 kSecAttrSynchronizableAny 时,所有查询都有效。当然,如果我确实想过滤该值,我也可以将其设置为 kCFBooleanTrue(或 *False)。

鉴于该属性,一切似乎对我来说都按预期工作。希望这可以为其他一些人节省半天的时间来处理测试代码。

于 2020-09-04T21:38:24.450 回答
2

更新

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

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

我发布了一个新问题(带有返回此问题的链接)。


原答案:

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

正如他所建议的,我下载了这个 Github 存储库(项目 28)中使用的 Keychain 包装类,并将我的代码替换为对主类的调用,你瞧——它确实有效

接下来,我添加了控制台日志,以将Keychain 包装器中用于存储/检索密码(即 和 的参数)的查询字典与我正在使用的字典进行比较。有几个不同之处:SecItemAdd()SecItemCopyMatching

  1. 包装器使用 Swift Dictionary ( [String, Any]),而我的代码使用NSDictionary(我必须更新它。已经是 2019 年了!)。
  2. 包装器使用捆绑标识符作为 的值kSecAttrService,我使用的是CFBundleName。这应该不是问题,但我的捆绑包名称包含日文字符...
  3. 包装器使用CFBooleankSecReturnData,我使用的是 Swift 布尔值。
  4. 包装器kSecAttrGeneric除了使用kSecAttrAccountand之外kSecAttrService,我的代码只使用后两者。
  5. 包装器将 和 的值编码kSecAttrGenerickSecAttrAccountData我的代码将值直接存储为String
  6. 我的插入字典使用kSecAttrAccessControland kSecUseAuthenticationUI,包装器不使用(它使用kSecAttrAccessible可配置的值。在我的情况下,我相信kSecAttrAccessibleWhenUnlocked适用)。
  7. 我的检索字典使用kSecUseOperationPrompt,包装器没有
  8. 包装器指定kSecMatchLimitvalue kSecMatchLimitOne,我的代码没有。

(第 6 点和第 7 点并不是真正需要的,因为虽然我最初设计课程时考虑到了生物认证,但我目前没有使用它。

...ETC。

我将我的字典与包装器的字典相匹配,最后让复制查询成功。然后,我删除了不同的项目,直到我可以查明原因。事实证明:

  1. 我不需要kSecAttrGeneric(就像@Edvinas 的回答中提到的那样)kSecAttrServicekSecAttrAccount
  2. 我不需要对 的值进行数据编码kSecAttrAccount(这可能是个好主意,但在我的情况下,它会破坏以前存储的数据并使迁移复杂化)。
  3. 事实证明kSecMatchLimit也不需要(也许是因为我的代码导致存储/匹配的唯一值?),但我想我会添加它只是为了安全(不觉得它会破坏向后兼容性)。
  4. 例如,Swift 布尔值可以kSecReturnData正常工作。但是分配整数 1会破坏它(尽管这是在控制台上记录值的方式)。
  5. (日语)捆绑名称作为值kSecService也可以。

...ETC。

所以最后,我:

  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 在他的回答中提到的钥匙串包装器(此存储库,项目 28))并继续前进!

于 2019-10-08T08:40:47.967 回答
2

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

基本上,Apple 似乎正在修复一个问题kSecAttrAccessControl,因此在 iOS 版本 13 之下,您添加了没有生物识别身份的 keyChain 对象,kSecAttrAccessControl而在 iOS 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]

查询常规 KeyChain(无生物特征):

var attributes: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                               kSecAttrService: "Your service",
                                               kSecAttrAccount: "Your account",
                                               kSecValueData: "data",
                                               kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
于 2020-02-17T22:38:30.773 回答
0

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

解决方案在 Apple 文档中:https ://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/storing_keys_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!
于 2019-11-01T12:02:40.863 回答