Каждое приложение, которое имеет авторизацию, рано или поздно получает секрет: пароль, токен доступа, refresh token, ключ шифрования локальной базы. Секрет нужно где-то держать между запусками, и первое, что приходит в голову, это UserDefaults или файл в Documents. Оба варианта работают и оба плохи по одной причине: данные лежат в песочнице приложения в открытом виде, попадают в резервные копии устройства и читаются любым, кто получил доступ к файловой системе. Keychain придуман ровно для того, чтобы закрыть эту дыру, и Apple в документации Keychain services описывает его как зашифрованную базу для небольших порций данных, которые пользователь доверил приложению. Там же сказано, что хранить в нём можно не только пароли, но и банковские данные, короткие заметки, а также криптографические ключи и сертификаты, которыми приложение пользуется без ведома человека.
Эта статья про устройство Keychain, про то, как записи лежат на диске и кто решает, что видит конкретный процесс. Примеры кода здесь сведены к минимуму, потому что операции add, read, update и delete вынесены в отдельную статью серии.
Для чего Keychain нужен
Keychain хранит небольшие значения: токен занимает десятки или сотни байт, ключ шифрования тридцать два, сертификат несколько килобайт. Файловым хранилищем и заменой базе данных он не служит. Когда нужно зашифровать большой файл или базу, обычный приём такой: сгенерировать случайный ключ, положить в Keychain только его, а сами данные шифровать этим ключом и хранить как обычно. Так делают, потому что Keychain проектировался для маленьких секретов, а не для объёмов, и в документации Apple слова «small chunks of data» стоят в самом первом предложении. Защита здесь устроена так: запись зашифрована, и вытащить её из файла базы или из резервной копии без ключей устройства нельзя, в этом его главная ценность. Но когда приложение запущено и устройство в нужном состоянии, система расшифрует запись и отдаст её, не спрашивая, как приложение собирается ею распорядиться. Keychain бессилен и тогда, когда секрет попал в лог, в крэш-репорт или в аналитику, поэтому прежде чем называть хранилище «безопасным» целиком, стоит помнить, что безопасность относится к хранению, а не к обращению с данными после чтения.
Самое неочевидное происходит при удалении приложения. На практике на iOS записи нередко переживают удаление и переустановку, и многие разработчики строят на этом логику, например сохраняют идентификатор установки. Но Apple не описывает такое поведение как гарантию, и опираться на него в бизнес-логике рискованно, потому что оно может отличаться между версиями системы и сценариями восстановления. Если приложению важно знать, что оно запущено впервые, надёжнее завести отдельный флаг и явно очищать Keychain при первом запуске, чем надеяться на конкретное поведение платформы.
Минимальный пример сохранения токена
Задача здесь простая: после входа пользователя сохранить токен в Keychain, чтобы приложение достало его при следующем запуске. Готового метода вроде save(token) у Keychain нет. Запись описывают словарём, где перечислено, что это за секрет, как его потом найти и в какие моменты системе разрешено отдавать его обратно, а затем словарь передают в системную функцию. Вот самый короткий рабочий вариант.
import Foundationimport Securityfunc saveToken(_ token: String) -> OSStatus { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.example.app.auth", kSecAttrAccount as String: "refresh-token", kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecValueData as String: Data(token.utf8) ] return SecItemAdd(query as CFDictionary, nil)}
Ключи словаря читаются так. kSecClass задаёт тип записи, и kSecClassGenericPassword («обычный пароль приложения», generic password) подходит для токенов и любых секретов, которые приложение хранит для себя. Пара kSecAttrService и kSecAttrAccount служит адресом: сервис обозначает область, к которой относится секрет, аккаунт называет сам секрет внутри этой области, и по этой паре запись потом находят. Сам секрет лежит в kSecValueData в виде набора байтов, поэтому строку токена переводят через Data(token.utf8).
Остаётся kSecAttrAccessible, и эта строка отвечает на вопрос, о котором стоит подумать уже при сохранении: в какие моменты система вправе отдать запись обратно приложению. Значение называют классом доступности. kSecAttrAccessibleAfterFirstUnlock означает, что после первой разблокировки телефона с момента включения запись можно читать до следующей перезагрузки, в том числе когда экран заблокирован. Для токена обычно так и нужно: приложение читает его в фоне, например после push-уведомления, и в этот момент телефон лежит заблокированным. Вариант kSecAttrAccessibleWhenUnlocked, наоборот, разрешает чтение только при разблокированном устройстве.
Функция SecItemAdd возвращает OSStatus, числовой код результата, и значение errSecSuccess означает, что запись сохранена. Этот код нужно проверять всегда, потому что именно в нём приходит ответ, получилось ли сохранить.
Если убрать из словаря строку kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, код продолжит работать, но поведение изменится: система возьмёт значение по умолчанию, то есть kSecAttrAccessibleWhenUnlocked, и запись станет читаемой только при разблокированном устройстве. Этой логике нужно уделять отдельное вниматение, так как это может ломать авторизацию пользователям в различных случаях.
Как записи лежат внутри
По описанию в Apple Platform Security каждая запись шифруется двумя разными ключами AES-256-GCM. Первый ключ, табличный, защищает метаданные, то есть все атрибуты записи кроме значения. Второй, построчный, защищает само секретное значение, которое лежит в kSecValueData. Зачем два ключа? Ответ в скорости поиска. Приложение почти никогда не читает запись целиком, оно сначала ищет её по атрибутам: сервис (kSecAttrService), аккаунт(kSecAttrAccount), группа доступа(kSecAttrAccessible). Ключ метаданных защищён Secure Enclave, но кэшируется в процессоре приложений, и поэтому поиск идёт быстро. А вот ключ секрета, как сказано в документе, всегда требует обращения к Secure Enclave, то есть каждое чтение значения проходит через отдельный аппаратный компонент.
Привычная фраза «Keychain лежит в Secure Enclave» неточна: в Enclave находятся ключи и логика проверки условий доступа, а сама база представляет собой обычный SQLite-файл на файловой системе, причём база одна на всё устройство.
Секрет нужно класть именно в kSecValueData, а не в произвольный атрибут вроде комментария или метки, потому что только значение защищено ключом, который требует похода в Secure Enclave при каждом чтении.
Раз база общая, кто-то должен решать, какие записи доступны какому приложению. Эту роль выполняет системный демон securityd: приложение вызывает функции Keychain, они превращаются в запросы к демону, а демон проверяет entitlements приложения. Так называют пары «ключ и значение», которые вшиты в подпись приложения и говорят системе, что ему разрешено. Они записаны в файле .entitlements проекта, а Xcode заполняет его, когда вы включаете возможности на вкладке Signing & Capabilities. Заявить можно только то, что разрешил профиль provisioning, который выдаёт Apple, иначе система откажется запускать приложение.
Для Keychain важны три разрешения, и каждое называет группу, записи которой приложение вправе видеть. keychain-access-groups содержит массив ваших собственных групп: его заполняет возможность Keychain Sharing, и в одну группу вступают приложения, которым нужно делиться записями. application-identifier Xcode добавляет при подписи сам, это идентификатор команды разработчика (Team ID), точка и bundle ID, например ABCDE12345.com.example.mail (Team ID здесь выдуманный). По умолчанию записи этой группы видит только само приложение. com.apple.security.application-groups перечисляет группы приложений (app groups) с именами вида group.com.example.suite: они нужны не только Keychain, их используют, например, приложение и его расширения, чтобы работать с общим хранилищем файлов.
Из этих трёх источников складывается список групп приложения, и порядок в нём важен: сначала группы из keychain-access-groups, затем application-identifier, затем группы приложений. Каждая запись принадлежит ровно одной группе, её имя хранится в атрибуте kSecAttrAccessGroup, и приложение видит запись, только если эта группа есть в его списке. Если при сохранении группу не указать, запись попадёт в первую группу списка. Общий доступ между приложением и расширениями разберём в одной из следующих статей, а пока достаточно знать, что процесс видит только записи групп из своего списка, а не всю базу.
Есть ещё одно ограничение от Apple: записями можно делиться только между приложениями одного разработчика. Префикс группы выдаётся через Apple Developer Program, это и есть Team ID, а его обязательность и неповторимость проверяются подписью кода и профилями provisioning, так что как бы вы ни назвали группу, чужое приложение с другим Team ID к вашим секретам не подберётся. Этим Keychain принципиально отличается от схем, где доступ определяется паролем или путём к файлу.
Посмотрим на примере. Допустим, у команды с Team ID ABCDE12345 есть два приложения, приложение с почтовым клиентом com.example.mail и приложение с календарем com.example.calendar, и им нужен общий токен. В файл .entitlements обоих приложений добавляют одну и ту же группу:
keychain-access-groups $(AppIdentifierPrefix)com.example.shared
Xcode при сборке заменяет $(AppIdentifierPrefix) на Team ID с точкой, так что в подписи окажется группа ABCDE12345.com.example.shared. Приложение с почтой сохраняет токен, указав эту строку в kSecAttrAccessGroup, а приложение с календарем находит его обычным запросом, потому что группа есть в списках обоих приложений. Теперь представим чужую команду с Team ID ZYXWV98765. Она может назвать свою группу com.example.shared, но получится ZYXWV98765.com.example.shared, то есть другая группа с другим префиксом. Заявить группу с вашим префиксом она не сможет: профиль provisioning разрешает команде только группы с её собственным Team ID, и подпись такого приложения не пройдёт проверку.
Классы доступности и атрибут kSecAttrAccessible
Как мы уже выяснили выше, при создании записи можно (а чаще всего даже нужно) указать атрибут kSecAttrAccessible, который задаёт, в каком состоянии устройства запись разрешено расшифровать. Значений у него несколько, и отличаются они тем, как долго ключ расшифровки остаётся в памяти. С kSecAttrAccessibleWhenUnlocked ключи доступны, только пока экран разблокирован, и после блокировки устройства система выгружает их из памяти. С kSecAttrAccessibleAfterFirstUnlock ключи после первой разблокировки с момента загрузки остаются в памяти до следующей перезагрузки, даже когда телефон снова заблокирован. kSecAttrAccessibleAlways открывает запись при любом состоянии, в том числе до первой разблокировки. Отдельно существует kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, о нём речь пойдёт ниже.
По умолчанию устанавливается WhenUnlocked: в документации Apple Restricting keychain item accessibility сказано, что если атрибут не задан, действует именно он. Из-за этого любой учебный пример, где вы вообще не упоминаете kSecAttrAccessible, работает только при открытом экране, и узнаёте вы об этом лишь тогда, когда запрос приходит из фона. Устройство без пароля при этом считается разблокированным всегда, так что на телефоне без пароля WhenUnlocked ведёт себя так, будто ограничения нет, и эту деталь почти никто не проверяет, потому что тестовые телефоны обычно защищены паролем. Для записей, к которым приложение обращается в фоне, Apple рекомендует AfterFirstUnlock: сервис фонового обновления, обработка push или завершение загрузки. Платить за это приходится более слабой защитой: пока устройство не перезагружали, ключ класса находится в памяти, и запись можно прочитать даже на заблокированном телефоне. Для токена, который нужен фоновой синхронизации, это обычно разумный компромисс. Для ключа, которым вы шифруете медицинские документы, я бы выбрал другое. Это моё мнение, а не требование документации, но логика такая: чем шире окно доступности, тем больше обстоятельств, при которых секрет можно достать из телефона в руках злоумышленника.
Устаревшим Apple считает Always: в документации константа помечена как deprecated начиная с iOS 12.0, с рекомендацией выбрать уровень, дающий пользователю хоть какую-то защиту. Новый код с этой константой лучше не писать вообще. Встретив её в чужом коде, стоит спросить автора, зачем она там.
Константа WhenPasscodeSetThisDeviceOnly по описанию Apple ведёт себя как WhenUnlocked, но доступна, только если на устройстве задан пароль, а при его снятии или сбросе запись теряет смысл, потому что система выбрасывает ключи класса. Такие записи живут только в системной связке ключей (system keybag): они не синхронизируются с iCloud Keychain, не попадают в резервные копии и не включаются в escrow keybags. Для секретов, которые не должны покидать устройство ни при каких обстоятельствах, это самый строгий из доступных вариантов. Отдельно скажу о суффиксе ThisDeviceOnly. Все остальные классы имеют вариант с таким суффиксом, и для него в документе описан механизм: при копировании записи с устройства, например при резервном копировании, она дополнительно защищается ключом UID. Этот ключ вшит в конкретное устройство, поэтому восстановленная на другом телефоне копия оказывается бесполезной. Если вы не хотите, чтобы токен переезжал при восстановлении из бэкапа на новый телефон, нужен именно вариант с ThisDeviceOnly. А если хотите, чтобы переезжал, выбирайте обычный.
Как это выглядит у самой системы
Выбор класса проще понять на примере того, как классы выбирает сама Apple для своих данных: в том же документе приведена таблица. Пароли от Wi-Fi, VPN, учётные записи почты, ключи iMessage, токены социальных сетей и токен iCloud доступны после первой разблокировки, потому что системе нужно подключаться к сети и забирать почту ночью, когда телефон лежит заблокированным на зарядке. Пароли Safari и закладки доступны только при разблокированном устройстве, поскольку никакой фоновой работе они не нужны. А токен APNs, ключи Bluetooth, PIN SIM-карты и сертификаты iCloud помечены как всегда доступные и при этом неперемещаемые (nonmigratory), потому что без них устройство не сможет даже подключиться к сети после перезагрузки.
У самой Apple тоже нет единого ответа на вопрос, какой класс правильный: выбор зависит от того, когда нужен секрет, и чем реже он нужен, тем строже должен быть класс. Если запись не нужно читать в фоне, нет причины открывать её на заблокированном телефоне. Практическое правило такое: начинайте с самого строгого класса, который не ломает сценарий, и расширяйте доступ, только когда столкнётесь с конкретной ошибкой.
Что происходит в фоне и после перезагрузки
Допустим, ночью телефон перезагрузился, и его никто ещё не разблокировал. Приходит push, система запускает ваше приложение в фоне, и код пытается прочитать токен, чтобы выполнить запрос. Если запись создана с классом по умолчанию, чтение не удастся: функция SecItemCopyMatching вернёт errSecInteractionNotAllowed, что в документации Security Framework Result Codes описано как запрет взаимодействия с Security Server. Такую же ошибку разработчики описывают в публичных баг-репортах, например при запуске приложения из фона на заблокированном устройстве. С AfterFirstUnlock чтение сработает, но только после того, как человек хотя бы раз разблокировал телефон с момента перезагрузки. Отсюда главная ловушка: пока вы отлаживаете приложение на разблокированном телефоне или в симуляторе, любой класс доступности работает одинаково, потому что устройство всегда в нужном состоянии. Баг не воспроизводится на устройстве в руках разработчика и появляется только в продакшене, причём в самый неудобный момент и у части пользователей. Чтобы поймать его заранее, нужен реальный телефон: сохраните токен, перезагрузите устройство, не вводя пароль, отправьте тихий push или запустите фоновую задачу и посмотрите на код возврата.
Для подстраховки заранее решите, что приложение делает в ответ на errSecInteractionNotAllowed: оно не считает это ошибкой аутентификации, не выходит из аккаунта и не удаляет токен, а откладывает операцию до следующей разблокировки. Цикл повторов с задержкой тут бессмысленен, потому что состояние устройства само по себе от ожидания не изменится. Но AfterFirstUnlock не стоит ставить на всё только потому, что он избавляет от этой ошибки: решение принимают для каждой записи отдельно, и если токену для фоновой синхронизации этот класс обычно подходит, то ключ, который открывает локальные документы лишь пока пользователь смотрит на экран, лучше оставить в WhenUnlocked.
Подтверждение пользователя поверх класса
Класс доступности отвечает на вопрос «в каком состоянии устройства запись можно открыть», но иногда нужно ещё и подтверждение человека: Face ID, Touch ID или пароль устройства. Для этого у записей есть списки контроля доступа (ACL), которые работают вместе с классом и его не заменяют. Подробности про биометрию вынесены в отдельную статью. В документации Apple сказано, что условия проверяются внутри Secure Enclave, а результат передаётся ядру, только если все ограничения выполнены. Можно потребовать, чтобы с момента создания записи набор зарегистрированных отпечатков или лицо не менялись, и это защищает от сценария, где злоумышленник добавляет собственный отпечаток и получает доступ.
Как выбрать класс
Рабочий алгоритм выбора выглядит так. Сначала спросите себя, нужен ли секрет, когда приложение работает в фоне. Если нет, берите WhenUnlocked, а если нужно запретить миграцию на другое устройство, WhenUnlockedThisDeviceOnly. Когда секрет нужен в фоне, берите AfterFirstUnlock или AfterFirstUnlockThisDeviceOnly. Если для вашего сценария необходимо быть уверенным, что на устройстве настроен пароль, и вы готовы потерять записи при его удалении, подойдёт WhenPasscodeSetThisDeviceOnly. Константу Always не используйте ни в каких случаях.
Каждый раз задаваться нужно двумя вопросами: нужно ли читать запись, пока телефон заблокирован, и должна ли она переезжать на другое устройство вместе с резервной копией.
В следующей части разберём, почему запись иногда недоступна и как отличить это от отсутствия записи.
Источник: habr.com