Запрос цепочки для ключей всегда возвращает errSecItemNotFound после обновления до iOS 13

Я храню пароли в связке ключей iOS, а затем извлекаю их для реализации функции "запомнить меня" (автоматический вход в систему) в моем приложении.

Я реализовал свою собственную оболочку для функций Security.framework (SecItemCopyMatching() и т.д.), И до iOS 12 она работала как чудо.

Сейчас я проверяю, что мое приложение не ломается с готовящейся к выпуску iOS 13, и вот:

SecItemCopyMatching() всегда возвращает .errSecItemNotFound

... хотя ранее я уже хранил данные, которые запрашиваю.

Моя оболочка - это класс со статическими свойствами для удобного предоставления значений kSecAttrService и kSecAttrAccount при сборке словарей запросов:

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), который демонстрирует проблему.

Хранение пароля всегда успешно; Загрузка пароля:

  • Успешно в Xcode 10 - iOS 12 (и более ранних версиях), но
  • Сбой с .errSecItemNotFound на Xcode 11 - iOS 13.

ОБНОВЛЕНИЕ: Я не могу воспроизвести проблему на устройстве, только симулятор. На устройстве сохраненный пароль успешно восстановлен. Возможно, это ошибка или ограничение в iOS 13 Simulator и/или iOS 13 SDK для платформы x86.

ОБНОВЛЕНИЕ 2: Если кто-то придумает альтернативный подход, который каким-то образом решит проблему (будь то по замыслу или с помощью некоторого контроля со стороны Apple), я приму его в качестве ответа.

Ответ 1

У меня была похожая проблема, когда я получал errSecItemNotFound с любым действием, связанным с цепочкой для ключей, но только на симуляторе. На реальном устройстве это было прекрасно, я тестировал последние X-коды (бета, GM, стабильный) на разных симуляторах, и те, которые доставляли мне трудности, были iOS 13.

Проблема заключалась в том, что я использовал kSecClassKey в атрибуте запроса kSecClass, но без "обязательных" значений (посмотрите, какие классы идут с какими значениями здесь) для генерации первичного ключа:

  • kSecAttrApplicationLabel
  • kSecAttrApplicationTag
  • kSecAttrKeyType
  • kSecAttrKeySizeInBits
  • kSecAttrEffectiveKeySize

И что помогло - это выбрать kSecClassGenericPassword для kSecClass и предоставить "необходимые" значения для генерации первичного ключа:

  • kSecAttrAccount
  • kSecAttrService

См. здесь, чтобы узнать больше о типах kSecClass и других атрибутах.

Я пришел к такому выводу, запустив новый проект iOS 13 и скопировав оболочку Keychain, которая использовалась в нашем приложении, как и ожидалось, что это не сработало, поэтому я нашел это прекрасное руководство по использованию цепочки для ключей здесь и опробовал их обертку, которая не удивила, и затем построчно сравнил мою реализацию с их.

Надеюсь это поможет.

Ответ 2

Благодаря предложению @Edvinas в его ответе выше я смог выяснить, в чем дело.

Как он предполагает, я скачал класс-обертку Keychain, используемый в этом репозитории Github (проект 28), и заменил свой код вызовами основного класса, и вот - это сработало.

Затем я добавил консольные журналы, чтобы сравнить словари запросов, используемые в оболочке брелка для хранения/извлечения пароля (т.е. аргументы для SecItemAdd() и SecItemCopyMatching), с теми, которые я использовал с помощью. Было несколько отличий:

  1. Оболочка использует Swift Dictionary ([String, Any]), а мой код использует NSDictionary (я должен обновить это. Уже 2019!).
  2. Оболочка использует идентификатор пакета для значения kSecAttrService, которое я использовал CFBundleName. Это не должно быть проблемой, но мое имя пакета содержит японские символы...
  3. Оболочка использует значения CFBoolean для kSecReturnData, я использовал булевы Swift.
  4. Оболочка использует kSecAttrGeneric в дополнение к kSecAttrAccount и kSecAttrService, мой код использует только последние два.
  5. Оболочка кодирует значения kSecAttrGeneric и kSecAttrAccount как Data, мой код хранил значения непосредственно как String.
  6. Мой словарь вставок использует kSecAttrAccessControl и kSecUseAuthenticationUI, а оболочка - нет (он использует kSecAttrAccessible с настраиваемыми значениями. В моем случае, я считаю, kSecAttrAccessibleWhenUnlocked применяется).
  7. Мой поисковый словарь использует kSecUseOperationPrompt, обертка не
  8. Оболочка указывает kSecMatchLimit на значение kSecMatchLimitOne, мой код - нет.

(Пункты 6 и 7 на самом деле не нужны, потому что , хотя я сначала разработал свой класс с учетом биометрической аутентификации, в настоящее время я не использую его.)

... и т.д..

Я сопоставил свои словари с словарями обертки и, наконец, получил запрос на копирование для успешного выполнения. Затем я удалил разные предметы, пока не смог определить причину. Оказывается, что:

  1. Мне не нужны kSecAttrGeneric (только kSecAttrService и kSecAttrAccount, как указано в ответе @Edvinas).
  2. Мне не нужно кодировать данные в значение kSecAttrAccount (это может быть хорошей идеей, но в моем случае это нарушит ранее сохраненные данные и усложнит миграцию).
  3. Оказывается, что kSecMatchLimit тоже не нужен (возможно, потому что мой код приводит к сохранению/сопоставлению уникального значения?), Но я предполагаю, что я добавлю его просто для безопасности (не похоже, что это нарушит обратную совместимость).
  4. Быстрые логические значения, например 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 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)) и двигаться дальше!