Записи, защищенные биометрией, в связке ключей iOS

Keychain API позволяет вашему приложению безопасно хранить небольшие фрагменты конфиденциальной информации. Можно установить разные параметры контроля доступа для каждой записи keyChain, тем самым определив набор требований, которые необходимо выполнить для доступа к ней. В моем предыдущем посте я описал, как защитить запись связки ключей с помощью пароля, предоставленного пользователем: Password-protected entries in iOS keychain

Теперь мы поговорим о защите записей связки ключей с помощью биометрии, то есть о том, что пользователю необходимо пройти аутентификацию Face ID или Touch ID для доступа к данным.

Чтобы иметь возможность использовать такие настройки контроля доступа для нашей записи в цепочке ключей, нам необходимо зарегистрировать отпечатки пальцев или сканирование Face ID на нашем устройстве iOS. Чтобы убедиться, что все настроено и включено, мы можем использовать метод canEvaluatePolicy класса LAContext. Вот один из способов сделать это:

enum BiometryState {
        case available, locked, notAvailable
    }

    var biometryState: BiometryState {
        let authContext = LAContext()
        var error: NSError?
        
        let biometryAvailable = authContext.canEvaluatePolicy(
            LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error)
        if let laError = error as? LAError, laError.code == LAError.Code.biometryLockout {
            return .locked
        }
        return biometryAvailable ? .available : .notAvailable
    }

В этом фрагменте мы различаем следующие три состояния:

  • available — все в порядке и мы готовы использовать биометрическую аутентификацию в нашем приложении
  • locked — биометрическая аутентификация в данный момент отключена (см. ниже)
  • notAvailable — биометрическая аутентификация недоступна

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

Также важно знать о заблокированном состоянии. После пяти последовательных неудачных попыток аутентификации с помощью Face ID или Touch ID биометрическая аутентификация отключается. Для повторного включения пользователю необходимо ввести пароль устройства. Ниже мы покажем, как реализовать это в коде.

Важно отметить, что Face ID: вы должны добавить строку описания использования в свой файл Info.plist. В противном случае аутентификация не будет работать на устройствах с Face ID. Имя соответствующего ключа — NSFaceIDUsageDescription. Однако для Touch ID такая строка не нужна.

Создание записи с биометрической защитой

Давайте теперь посмотрим, как создать keychain с биометрической защитой. Это относительно легко. Как и для любой другой записи keychain, мы должны использовать вызов SecItemAdd:

static func getBioSecAccessControl() -> SecAccessControl {
        var access: SecAccessControl?
        var error: Unmanaged<CFError>?
        
        if #available(iOS 11.3, *) {
            access = SecAccessControlCreateWithFlags(nil,
                kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
                .biometryCurrentSet,
                &error)
        } else {
            access = SecAccessControlCreateWithFlags(nil,
                kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
                .touchIDCurrentSet,
                &error)
        }
        precondition(access != nil, "SecAccessControlCreateWithFlags failed")
        return access!
    }

    static func createBioProtectedEntry(key: String, data: Data) -> OSStatus {
        let query = [
            kSecClass as String       : kSecClassGenericPassword as String,
            kSecAttrAccount as String : key,
            kSecAttrAccessControl as String: getBioSecAccessControl(),
            kSecValueData as String   : data ] as CFDictionary
        
        return SecItemAdd(query as CFDictionary, nil)
    }

Главное здесь — установить специальный экземпляр SecAccessControl для ключа kSecAttrAccessControl при подготовке параметра словаря запроса для вызова SecItemAdd. Чтобы создать этот экземпляр, мы вызываем SecAccessControlCreateWithFlags со следующими параметрами:

  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly — запись нашей связки ключей может быть прочитана только тогда, когда устройство iOS разблокировано. Также он не будет скопирован на другие устройства через iCloud и не будет добавлен в резервные копии.
  • .biometryCurrentSet — задает требование аутентификации Touch ID или Face ID. Он строго привязывает вашу запись к текущим зарегистрированным биометрическим данным. Обратите внимание, что до iOS 11.3 это был .touchIDCurrentSet. Этот флаг не позволяет использовать пароль вместо сканирования отпечатка пальца или лица, если вы хотите, чтобы вместо этого вы могли использовать .userPresence.

Чтение записи, защищенной биометрией

Теперь, как мы можем прочитать запись, которую мы только что создали? Как и в случае с защищенными паролем записями, здесь есть несколько немного отличающихся вариантов. Первый вариант — синхронно вызвать SecItemCopyMatching и позволить ему представить пользовательский интерфейс аутентификации Touch ID или Face ID. Второй вариант — использовать экземпляр LAContext для аутентификации.

Перед использованием биометрической аутентификации важно убедиться, что наше iOS-устройство к этому готово. Должна быть включена биометрия и должны присутствовать отпечатки пальцев или сканирование лица. Для выполнения этой проверки мы можем использовать такой метод:

private func checkBiometryState(_ completion: @escaping (Bool)->Void) {
        let bioState = self.biometryState
        guard bioState != .notAvailable else {
            // Can't read entry, biometry not available
            completion(false)
            return
        }
        if bioState == .locked {
            // To unlock biometric authentication iOS requires user to enter a valid passcode
            let authContext = LAContext()
            authContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication,
                                       localizedReason: "Access sample keychain entry",
                                       reply: { (success, error) in
                DispatchQueue.main.async {
                    if success {
                        completion(true)
                    } else {
                        // Can't read entry, check error for details
                        completion(false)
                    }
                }
            })
        } else {
            completion(true)
        }
    }

Здесь мы используем свойство biometryState, которое мы определили в начале этой статьи.

Если биометрическая аутентификация временно отключена, нам нужно попросить пользователя разблокировать ее, введя пароль устройства. Мы делаем это, вызывая authContext.evaluatePolicy со значением deviceOwnerAuthentication, переданным в качестве первого аргумента (это политика, которая нас интересует). Он запускает экран ввода пароля устройства.

Обратите внимание, что метод AssessmentPolicy вызывает свой параметр обратного вызова в фоновом потоке. Поэтому, если мы хотим выполнить какой-то код пользовательского интерфейса, нам нужно поместить его в вызов DispatchQueue.main.async.

Когда мы уверены, что биометрическая аутентификация доступна, мы можем пойти дальше и прочитать содержимое записи. Для этого мы будем использовать следующий метод:

static func loadBioProtected(key: String, context: LAContext? = nil,
                                 prompt: String? = nil) -> Data? {
        var query: [String: Any] = [
            kSecClass as String       : kSecClassGenericPassword,
            kSecAttrAccount as String : key,
            kSecReturnData as String  : kCFBooleanTrue,
            kSecAttrAccessControl as String: getBioSecAccessControl(),
            kSecMatchLimit as String  : kSecMatchLimitOne ]
        
        if let context = context {
            query[kSecUseAuthenticationContext as String] = context
            
            // Prevent system UI from automatically requesting Touc ID/Face ID authentication
            // just in case someone passes here an LAContext instance without
            // a prior evaluateAccessControl call
            query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUISkip
        }
        
        if let prompt = prompt {
            query[kSecUseOperationPrompt as String] = prompt
        }

        var dataTypeRef: AnyObject? = nil
        
        let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
        
        if status == noErr {
            return (dataTypeRef! as! Data)
        } else {
            return nil
        }
    }

Здесь ключ kSecUseOperationPrompt используется для установки сообщения, которое будет отображаться в запросе системной биометрической аутентификации. Обратите внимание, что он используется только в том случае, если вы следуете первому подходу, описанному ниже. Для второго подхода (с LAContext.evaluateAccessControl) параметр localizedReason этого вызова будет использоваться для той же цели.

Первый подход: без LAContext

Давайте рассмотрим первый способ чтения записи, где мы вызываем SecItemCopyMatching без экземпляра LAContext в его параметрах (внутри словаря запросов). Это соответствует вызову loadBioProtected с параметром контекста, установленным на nil:

checkBiometryState { success in
        guard success else {
            // Biometric authentication is not available
            return
        }
        DispatchQueue.global().async {
            var result = ""
            if let data = KeychainHelper.loadBioProtected(key: self.entryName,
                                                          prompt: "Access sample keychain entry") {
                let dataStr = String(decoding: data, as: UTF8.self)
                result = "Keychain entry contains: \(dataStr)"
            } else {
                result = "Couldn't read entry"
            }
            DispatchQueue.main.async {
                self.showStatus(result)
            }
        }
    }

Это простой пример чтения строкового значения из записи с именем, хранящейся в константе с именем entryName, и вызова примера метода showStatus для отображения результата пользователю.

Здесь SecItemCopyMatching будет вызываться синхронно, поэтому мы делаем это в фоновом потоке, чтобы предотвратить блокировку основного потока. Вызов запускает процесс аутентификации Touch ID или Face ID и возвращается только после его завершения. Если аутентификация прошла успешно, функция возвращает 0 (noErr). Если что-то пошло не так, возвращает код ошибки. Вот несколько возможных кодов, которые может вернуть SecItemCopyMatching:

  • 128 (errSecUserCanceled) — процесс аутентификации был отменен пользователем
  • 25300 (errSecItemNotFound) — запись связки ключей не найдена
  • 25293 (errSecAuthFailed) — ошибка аутентификации

Второй подход: использование LAContext.evaluateAccessControl

В качестве опции мы можем выполнить биометрическую аутентификацию для экземпляра LAContext, избегая блокирующего вызова SecItemCopyMatching. Для этого нам нужен новый экземпляр LAContext и экземпляр SecAccessControl, которые можно создать с помощью метода getBioSecAccessControl, определенного выше.

Затем мы вызываем метод authContext.evaluateAccessControl, запрашивающий у пользователя аутентификацию Touch ID или Face ID. Обратите внимание, что функция AssessmentAccessControl вызывает свой обратный вызов в фоновом потоке, поэтому не забудьте вызвать DispatchQueue.main.async, где это уместно.

Если аутентификация прошла успешно, мы используем наш экземпляр authContext для чтения содержимого записи цепочки для ключей:

checkBiometryState { success in
        guard success else {
            // Biometric authentication is not available
            return
        }
        let authContext = LAContext()
        let accessControl = KeychainHelper.getBioSecAccessControl()
        authContext.evaluateAccessControl(accessControl,
                                          operation: .useItem,
                                          localizedReason: "Access sample keychain entry") {
            (success, error) in
            var result = ""
            if success, let data = KeychainHelper.loadBioProtected(key: self.entryName,
                                                                   context: authContext) {
                let dataStr = String(decoding: data, as: UTF8.self)
                result = "Keychain entry contains: \(dataStr)"
            } else {
                result = "Can't read entry, error: \(error?.localizedDescription ?? "-")"
            }
            DispatchQueue.main.async {
                self.showStatus(result)
            }
        }
    }

Когда вызывается loadBioProtected, наш экземпляр authContext помещается в словарь запросов для ключа kSecUseAuthenticationContext — это гарантирует, что ранее выполненная аутентификация будет учтена последующим вызовом SecItemCopyMatching.

Заключительные заметки

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

Обратите внимание, что API цепочки для ключей не полностью работает на симуляторе iOS. Вам нужно реальное устройство, чтобы использовать его. API тесно взаимодействует с аппаратным обеспечением безопасного анклава и не полностью реализован в симуляторе.

Еще одна важная вещь, которую следует помнить: когда пользователь меняет свои настройки безопасности, такие как изменение кода доступа, добавление или удаление отпечатков пальцев или сканирование Face ID, все записи, защищенные биометрией, удаляются (становятся недоступными). Если вы хотите сохранить их (несколько жертвуя безопасностью), вы можете использовать флаг .biometryAny (.touchIDAny для более старых версий iOS) вместо .biometryCurrentSet при создании экземпляра SecAccessControl (см. метод getBioSecAccessControl, определенный выше).

Для получения дополнительной информации вы можете посмотреть официальную документацию Apple.