Keychain представляет собой зашифрованную базу небольших секретов, которую ведёт система: приложение обращается к ней только через API SecItem, а каждый элемент зашифрован ключами, которые защищены кодом-паролем устройства и Secure Enclave.
Кто её держит: база лежит вне контейнера приложения, её обслуживает демон securityd. Ваш процесс отправляет словари-запросы в SecItemAdd, SecItemCopyMatching, SecItemUpdate и SecItemDelete.
Как она зашифрована: у каждого элемента свой ключ AES-256, обёрнутый ключом класса, а тот, в свою очередь, защищён кодом-паролем и аппаратным ключом устройства. Поэтому класс вроде WhenUnlocked может сделать элемент доступным только пока телефон разблокирован.
Что вы выбираете: kSecAttrAccessible (WhenUnlocked, AfterFirstUnlock, …ThisDeviceOnly без бэкапа и переноса через iCloud). Элементы переживают удаление приложения, поэтому после переустановки можно найти старый токен.
Кто может прочитать элемент: только приложения, в подписанных entitlements которых указана его access group (она начинается с вашего Team ID); приложение другого разработчика его не найдёт.
**Типы элементов (kSecClass)**: kSecClassGenericPassword для токенов и секретных данных, kSecClassInternetPassword для учётных данных сервера, kSecClassKey, kSecClassCertificate и kSecClassIdentity (сертификат вместе с приватным ключом).
Только небольшие значения: токены, пароли, ключи. Большой файл шифруют на диске, а в Keychain кладут только его ключ.
iOSMiddle
Безопасность, приватность и Keychain
Классы защиты данных, ATS и TLS, pinning, биометрия, jailbreak, секреты в сборке, разрешения, ATT и геолокация
Закрыто вопросов
0 / 20
- 0Повторить
- 2Часто спрашивают
Вопросы
Нет. Всё, что есть в .ipa, получает каждый пользователь: Info.plist лежит в бандле обычным читаемым файлом, а строковые константы лежат в бинарнике, так что ключ достают за минуты. Ключ, который даёт реальные возможности, остаётся на вашем сервере, а приложение ходит в ваш бэкенд.
Как он утекает: .ipa представляет собой zip-архив: распакуйте его и откройте Info.plist или запустите strings на бинарнике. На джейлбрейкнутом телефоне значение можно прочитать и из памяти.
**.xcconfig решает другую задачу**: Secrets.xcconfig в .gitignore или переменная CI держат ключ вне репозитория, но сборка всё равно копирует значение в приложение.
Обфускация: XOR или разбиение строки на части только замедляют атакующего; это «лежачий полицейский», а не защита.
Прокси на бэкенде: ключ вендора живёт только на вашем сервере. Приложение отправляет свой токен сессии, а сервер пересылает вызов и применяет квоты по аккаунтам. Смена ключа сводится к деплою сервера, без новой сборки.
Если ключ обязан быть в приложении (SDK карт, аналитика): ограничьте его у вендора вашим bundle ID, поставьте квоты и алерты и считайте его публичным.
Доказать, что запрос пришёл от вашего приложения: App Attest позволяет бэкенду отклонять запросы от скрипта, который скопировал ключ.
Создайте LAContext, вызовите canEvaluatePolicy, чтобы проверить, что биометрия доступна и настроена, затем evaluatePolicy со строкой-причиной. Для Face ID ещё нужен NSFaceIDUsageDescription в Info.plist.
Политики: .deviceOwnerAuthenticationWithBiometrics принимает только Face ID или Touch ID и оставляет запасной вариант вам; .deviceOwnerAuthentication сама переходит на код-пароль устройства.
Ошибки доступности: biometryNotAvailable (нет железа или пользователь отказал Face ID, когда система спросила один раз), biometryNotEnrolled и biometryLockout после слишком многих неудач, пока не введён код-пароль.
Исходы: userFallback, когда пользователь нажал вашу запасную кнопку, userCancel, systemCancel при уходе приложения в фон, authenticationFailed.
**biometryType** отличает Face ID от Touch ID для подписей на кнопках; он заполняется только после вызова canEvaluatePolicy.
Ограничение: результат сводится к Bool внутри вашего процесса. Годится, чтобы спрятать экран, но не чтобы защитить секрет: для этого сам элемент Keychain должен требовать биометрию через SecAccessControl.
Приложение объявляет строку с назначением в Info.plist, а система спрашивает пользователя один раз, при первом запросе. Без NSCameraUsageDescription приложение убивается при этом первом обращении; после «Не разрешать» запрос больше не появится, и вернуть доступ может только сам пользователь в Настройках.
Нет ключа: процесс завершается с сообщением «This app has crashed because it attempted to access privacy-sensitive data without a usage description». App Review к тому же отклоняет размытые строки: напишите, что функция делает с камерой.
Статусы: .notDetermined (можно спросить), .authorized, .denied и .restricted (родительский контроль или профиль устройства, который пользователь тоже не может изменить).
Спрашивайте в контексте: когда пользователь нажал на функцию, а не при запуске.
После «Не разрешать»: новый запрос возвращает false без диалога. Объясните, чего не хватает, дайте ссылку на UIApplication.openSettingsURLString и оставьте остальное приложение рабочим.
Проверяйте, а не кэшируйте: пользователь может в любой момент изменить разрешение в Настройках, поэтому читайте статус при каждом запуске и выходе на передний план.
TLS даёт запросу шифрование, целостность и подтверждение того, кто сервер. В рукопожатии клиент и сервер договариваются о версии, шифре и общем ключе через ECDHE, а сервер доказывает, что владеет закрытым ключом сертификата; клиент проверяет этот сертификат, прежде чем отправить запрос.
Рукопожатие (TLS 1.3, один round trip): клиент присылает версии, шифры и свою часть ключа (key share); сервер отвечает своим выбором, своей частью ключа, цепочкой сертификатов и подписью под рукопожатием, сделанной закрытым ключом сертификата.
Forward secrecy: сессионные ключи получаются из одноразовых ключей ECDHE, поэтому украденный позже ключ сервера не расшифрует записанный трафик. ATS этого требует. Дальше каждая запись шифруется и аутентифицируется, поэтому изменённый байт отбрасывается.
**Что проверяет URLSession**: цепочку до корневого сертификата, которому доверяет устройство, сроки действия и имя хоста в Subject Alternative Name. При ошибке приходит код вроде -1202.
Где защита заканчивается: корневой сертификат, который пользователь установил и сделал доверенным, например от отладочного прокси, тоже проходит проверку; эту дыру закрывает pinning. А делегат, который возвращает .useCredential без вызова SecTrustEvaluateWithError, отключает проверки.
Каждый файл шифруется ключом класса, который решает, когда его можно прочитать: complete разрешает это только при разблокированном устройстве, при completeUnlessOpen уже открытые файлы работают и после блокировки, completeUntilFirstUserAuthentication (по умолчанию) разрешает после первой разблокировки с момента загрузки, а none разрешает всегда.
**complete: ключ класса удаляется примерно через 10 секунд после блокировки, и чтение падает с ошибкой доступа. Подходит для выписок и медицинских документов.
completeUnlessOpen: файл, открытый до блокировки, остаётся доступным, и новые файлы всё ещё можно создавать; сделан для загрузки, которая должна завершиться на заблокированном телефоне.
completeUntilFirstUserAuthentication: класс по умолчанию. Он защищает телефон, который перезагрузили и ещё не разблокировали, и только: фоновый код после первой разблокировки читает такой файл свободно.
none: защищён только ключом устройства; для файлов, которые нужно читать при запуске до первой разблокировки.
Как задать**: при записи (.completeFileProtection), через FileManager.setAttributes или для всего приложения через capability Data Protection.
Ловушка: код, который работает при заблокированном телефоне, получает ошибку на файле с complete. Проверяйте isProtectedDataAvailable и закрывайте файлы по protectedDataWillBecomeUnavailableNotification.
Токен сохранён как kSecAttrAccessibleWhenUnlocked, поэтому фоновая синхронизация на заблокированном телефоне получает errSecInteractionNotAllowed. readToken() превращает эту ошибку в nil, и приложение разлогинивает пользователя с совершенно рабочим токеном.
Почему так происходит: элемент WhenUnlocked расшифровывается только при разблокированном устройстве. Silent push и BGAppRefreshTask обычно выполняются при заблокированном экране, поэтому вскоре после блокировки чтение падает с -25308 именно там и только там, отсюда ощущение случайности.
Две ошибки складываются: неверный класс доступности ломает чтение, а сведение любого неуспешного статуса к nil делает «сейчас прочитать нельзя» неотличимым от «токена нет».
Исправьте класс: токен, который нужен фоновому коду, храните как kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Элементы, уже лежащие на телефонах пользователей, сохраняют старый класс, поэтому мигрируйте их через SecItemUpdate при запуске в foreground.
Исправьте обработку ошибок: разлогин означает только errSecItemNotFound. errSecInteractionNotAllowed возможен и с новым классом (после перезагрузки до первой разблокировки), поэтому трактуйте его как «попробовать позже».
Та же ловушка в других местах: виджеты, notification service extension и обработчики завершения фонового URLSession тоже работают на заблокированном телефоне.
ATS отклоняет TLS-рукопожатие, потому что сервер не дотягивает до минимума Apple: TLS 1.2 или новее, шифр с forward secrecy (ECDHE) и сертификат с подписью SHA-256 на ключе RSA от 2048 бит или ECC от 256 бит. Браузеры мягче, поэтому подключаются.
Диагностика на Mac: nscurl --ats-diagnostics --verbose https://api.example.com повторяет подключение, ослабляя по одной настройке ATS, и показывает, какое исключение помогло бы.
Как читать ошибку: -1200 означает NSURLErrorSecureConnectionFailed; вложенный -9802 означает фатальный TLS alert. Переменная окружения CFNETWORK_DIAGNOSTICS со значением 3 в схеме выводит в консоль подробности рукопожатия.
Сначала чините сервер: TLS 1.2/1.3 и наборы ECDHE включаются настройкой балансировщика, и она помогает всем клиентам.
Узкое исключение, если нельзя: в NSExceptionDomains задайте NSExceptionMinimumTLSVersion (например, TLSv1.0) или NSExceptionRequiresForwardSecrecy со значением NO, только для этого домена. App Review может попросить обоснование.
Куда ATS не дотягивается: он действует для URLSession и WKWebView, но не для низкоуровневых API вроде Network framework или сырых сокетов. Библиотека на них обходит и проверку, и защиту, которую она даёт.
При несовпадении пина делегат откатывается на .performDefaultHandling, поэтому принимается любой сертификат, подписанный доверенным CA, и пиннинг ничего не защищает. Проверка пина лишь выбирает, какая ветка выполнится.
Почему всё выглядит нормально: настоящий сервер совпадает с пином, и приложение работает. Дыра видна только на другой валидной цепочке, например у прокси, чей CA установил пользователь, или у сертификата, ошибочно выпущенного публичным CA.
Что значит обработка по умолчанию: система выполняет обычную проверку доверия, и цепочка, заканчивающаяся корнем из хранилища доверенных, проходит. Это именно та проверка, которую пиннинг должен ужесточить.
Исправление: при несовпадении отменяйте через .cancelAuthenticationChallenge; запрос завершится с NSURLErrorCancelled (-999).
Системную проверку оставьте: сначала вызовите SecTrustEvaluateWithError, чтобы срок действия и имя хоста по-прежнему проверялись. Пиннинг сужает доверие, а не заменяет его.
Проверьте это: запустите прокси вроде mitmproxy с его CA, которому доверяет устройство; запрос должен завершиться ошибкой.
SHA256.hash даёт отпечаток, AES.GCM.seal (или ChaChaPoly) шифрует и одновременно обнаруживает подмену, а HMAC доказывает, кто создал сообщение. SymmetricKey хранится в Keychain, но никогда в бандле или рядом с зашифрованным файлом.
Хеш не является шифрованием: SHA-256 односторонний и не требует ключа; он нужен для проверки целостности и ключей кеша.
Аутентифицированное шифрование: AES-GCM и ChaChaPoly добавляют тег, и open бросает ошибку, если изменился хоть один байт. У сырого AES-CBC из CommonCrypto такой проверки нет, и им легко воспользоваться неправильно.
Nonce: seal сам выбирает случайный nonce, а combined упаковывает nonce, шифротекст и тег в один блоб для хранения. Повтор nonce с тем же ключом ломает GCM, поэтому без причины не передавайте свой.
Ключи из секретов: HKDF выводит ключи из данных с высокой энтропией, например из результата key agreement. Для пароля пользователя нужна медленная KDF вроде PBKDF2 (CCKeyDerivationPBKDF в CommonCrypto).
Куда кладут ключ: сгенерируйте его один раз и сохраните байты как элемент kSecClassGenericPassword с классом ThisDeviceOnly. Без него файл бесполезен, а удаление элемента мгновенно стирает данные.
Unified log скрывает интерполированные значения, которые вы не пометили .public: строки и объекты выводятся как <private>, а числа и булевы значения по умолчанию публичны. Токены, пароли и одноразовые коды не логируют вообще, ни скрытыми, ни открытыми.
Кто может прочитать лог: любой, у кого есть разблокированный телефон и Mac с Console, sysdiagnose, который пользователь отправляет в поддержку, и диагностические отчёты. Приватные значения там остаются скрытыми.
Когда приватное видно: при подключённом отладчике Xcode или когда конфигурационный профиль логирования включает приватные данные. .private защищает логи в продакшене, а не вашу отладочную сессию.
**.private(mask: .hash): заменяет значение хешем, так что видно, что две строки относятся к одному аккаунту, но сам аккаунт не виден.
print и NSLog**: print пишет в stdout (виден под отладчиком) и не знает про приватность. NSLog до iOS 26 писал всё открытым текстом; на iOS 26 его динамические строковые аргументы в unified log заменяются на <private> (консоль Xcode и stdout не затронуты), а для нескрытых значений Apple советует os_log или Logger, поэтому ведите всё через Logger, а print уберите из релиза.
Что логировать вместо этого: идентификаторы, коды статусов, длительности и домены ошибок; не тела запросов, номера карт, точную геолокацию или что-либо, что ввёл пользователь.
Сторонние SDK: крэш-репортеры и аналитика собирают breadcrumbs и кастомные ключи; к тому, что вы им передаёте, применяются те же правила.
В iOS нет флага, который запрещает скриншоты, поэтому контент закрывают сами: показывают защитный оверлей, как только сцена становится неактивной (до того, как система сделает снапшот для переключателя приложений), и прячут чувствительные view, пока sceneCaptureState равен .active.
Когда делается снапшот: система снимает UI после ухода приложения в фон. Если закрывать его в sceneWillResignActive (.inactive в SwiftUI), балансы скрываются и тогда, когда опускается Control Center или баннер звонка.
Запись и дублирование экрана: traitCollection.sceneCaptureState (iOS 17+, до этого UIScreen.isCaptured) сообщает о записи экрана, AirPlay и дублировании. Пока он .active, прячьте номера карт и балансы.
Скриншоты: userDidTakeScreenshotNotification приходит уже после того, как скриншот сохранён. Можно предупредить пользователя или залогировать событие, но не помешать.
Буфер обмена: копируйте коды через UIPasteboard.general.setItems(_:options:) с .localOnly (без Universal Clipboard) и .expirationDate.
Клавиатуры: защищённые поля (isSecureTextEntry, SecureField) отключают сторонние клавиатуры и подсказки. Чтобы запретить кастомные клавиатуры везде, верните false из application(_:shouldAllowExtensionPointIdentifier:) для .keyboard.
ATT управляет разрешением, без которого приложение не может отслеживать пользователя в приложениях и на сайтах других компаний, в том числе читать IDFA. Запрос появляется только пока приложение активно и только один раз; после отказа IDFA состоит из нулей, а атрибуция переходит на SKAdNetwork или AdAttributionKit.
Что считается трекингом: связывание данных вашего приложения с данными других компаний для таргетированной рекламы или её измерения, а также передача их брокеру данных. Ваша собственная аналитика и реклама только на ваших данных трекингом не считаются.
Запрос: ATTrackingManager.requestTrackingAuthorization() и NSUserTrackingUsageDescription. Если вызвать его, пока приложение не активно, например из didFinishLaunching, он сразу вернётся без диалога.
Когда спрашивать: после онбординга, когда сцена активна, при желании после собственного экрана с объяснением. Награды за согласие или поддельный системный алерт приводят к отклонению приложения.
После отказа: advertisingIdentifier равен 00000000-0000-0000-0000-000000000000, а фингерпринтинг или сопоставление хэшей email с рекламной сетью тоже запрещены. identifierForVendor по-прежнему работает для вашей собственной аналитики.
Атрибуция без IDFA: SKAdNetwork и AdAttributionKit сообщают рекламной сети об установках и грубых значениях конверсии, агрегированно, с задержкой и без идентификатора пользователя.
When In Use отдаёт координаты, пока приложением пользуются; Always вдобавок позволяет системе перезапускать приложение ради геозон, посещений и значительных перемещений. С выключенной «Точной геопозицией» место приблизительное: круг радиусом около 5 км, который пересчитывается примерно четыре раза в час.
When In Use в фоне: обновления, запущенные на экране, продолжаются с фоновым режимом location и allowsBackgroundLocationUpdates (или с CLBackgroundActivitySession на iOS 17+).
Always: для мониторинга регионов, посещений и значительных изменений местоположения, которые перезапускают приложение. Сначала просите When In Use, а повышение позже, когда оно понадобится функции: этот запрос срабатывает только один раз.
Info.plist: NSLocationWhenInUseUsageDescription, а для Always ещё NSLocationAlwaysAndWhenInUseUsageDescription, иначе запрос сразу завершается неудачей.
«Точная геопозиция» выключена (.reducedAccuracy): радиус составляет пару километров в плотном городе и 10 км и больше за городом. Мониторинг регионов перестаёт работать; значительные изменения и посещения продолжаются с грубыми координатами. Показывайте результат на уровне города, а не пин не на той улице.
Когда точность обязательна: requestTemporaryFullAccuracyAuthorization(withPurposeKey:) со строкой назначения из NSLocationTemporaryUsageDescriptionDictionary, в тот момент, когда она нужна функции; разрешение действует до конца сессии.
Она ловит только старый rootful-джейлбрейк с Cydia и немодифицированную копию приложения. Современные rootless-джейлбрейки держат файлы в /var/jb, а твики для скрытия перехватывают fileExists и canOpenURL, так что каждая проверка возвращает false.
Пути устаревают: rootless-джейлбрейки вроде Dopamine и palera1n не трогают /Applications и /bin, а каждый новый джейлбрейк меняет список.
**canOpenURL**: возвращает false для любой схемы, которой нет в LSApplicationQueriesSchemes, так что без этой записи проверка никогда не срабатывает.
Проба записи: разумный тест целостности песочницы, но перехватывается так же легко, как остальное.
Один патч ломает всё: результат сводится к одному Bool из одной функции; Frida или патч бинарника заставляют её вернуть false.
**exit(0): выглядит как краш, наказывает пользователей с ложным срабатыванием и прямо указывает атакующему, какую функцию патчить.
Что делать вместо этого**: отправлять результат на сервер как один из сигналов риска, рядом с assertion из App Attest. Секреты держать в Keychain или Secure Enclave, а рискованные операции вроде перевода или нового устройства пусть решает сервер, с дополнительной проверкой, когда сигналы выглядят плохо.
Secure Enclave представляет собой отдельный сопроцессор, который генерирует и использует ключи, никогда не показывая их iOS. В CryptoKit вы создаёте ключ SecureEnclave.P256, сохраняете его зашифрованный дескриптор и просите анклав подписать данные или выполнить key agreement.
Что вы получаете: ключ P-256 для Signing (ECDSA) или KeyAgreement (ECDH). Классическая кривая только P-256 (ни RSA, ни «сырого» AES): чтобы шифровать данные, выводят симметричный ключ из key agreement. В iOS 26 добавились ключи ML-KEM и ML-DSA в анклаве.
**dataRepresentation**: не сам ключ, а зашифрованный блоб, которым может воспользоваться только анклав этого устройства. Храните его в Keychain и восстанавливайте ключ через init(dataRepresentation:).
Контроль доступа: .privateKeyUsage обязателен; если добавить .biometryCurrentSet, каждая подпись требует Face ID, а ключ перестаёт работать при смене зарегистрированной биометрии.
Ни экспорта, ни бэкапа: новый телефон означает новый ключ и новую регистрацию на сервере. Закладывайте это с самого начала.
Доступность: в симуляторе SecureEnclave.isAvailable равен false, поэтому для тестов нужен протокол вокруг ключа и программная замена.
Типичное применение: привязка к устройству: при входе регистрируете публичный ключ, затем подписываете challenge сервера, и украденного refresh-токена уже недостаточно, чтобы действовать от имени пользователя.
Они задают, что система потребует перед расшифровкой элемента: .userPresence принимает биометрию или код-пароль, .biometryAny принимает любое зарегистрированное лицо или палец, .biometryCurrentSet принимает только набор, зарегистрированный при сохранении элемента; после новой регистрации такой элемент больше не прочитать.
evaluatePolicy отдаёт вашему коду Bool, и хук может его подменить. С SecAccessControl элемент расшифровывается только после успешного совпадения, так что патчить нечего.
Выбор флага
**.biometryCurrentSet**: для банка: тот, кто подсмотрел код-пароль, не сможет добавить своё лицо и открыть аккаунт. За это платят восстановлением: после перерегистрации чтение падает (обычно с errSecItemNotFound), поэтому нужен полный вход и сохранение нового элемента.
**.biometryAny: переживает добавленный палец; подходит для удобной разблокировки.
.userPresence**: запасной вариант с кодом-паролем встроен; защищает от постороннего, но не от того, кто знает код-пароль.
Флаги комбинируются, например [.biometryCurrentSet, .or, .devicePasscode].
Практические детали
Класс доступности передаётся в SecAccessControlCreateWithFlags; задать ещё и kSecAttrAccessible нельзя. С kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly элемент удаляется, если пользователь выключит код-пароль.
Добавление элемента не показывает запрос; чтение показывает Face ID и блокирует вызывающий поток, пока пользователь не ответит, поэтому никогда не читайте на главном потоке.
Передайте LAContext в kSecUseAuthenticationContext, чтобы задать текст запроса и переиспользовать только что прошедшую аутентификацию вместо повторного вопроса.
Отменённый запрос возвращает errSecUserCanceled (-128): оставайтесь на экране блокировки и не считайте это выходом из аккаунта.
Выпускайте пин следующего ключа заранее: каждый релиз пиннит текущий ключ и заранее сгенерированный резервный, так что сервер может переключиться на резервный, а старые сборки продолжат подключаться. Ротацию планируют по самой старой версии приложения в использовании, а не по дате истечения сертификата.
До первого релиза
Сгенерируйте резервную пару ключей сейчас и держите приватный ключ офлайн (HSM или хранилище секретов). Пинните SPKI-хеши обоих ключей через NSPinnedDomains (iOS 14+), так что продление на том же ключе ничего не меняет.
Пин считается из сертификата: openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64.
Если ключ не будет использоваться повторно, пинните выпускающий CA (NSPinnedCAIdentities).
Сама ротация
Перед переключением проверьте, какая доля активных установок уже несёт резервный пин, а для остальных требуйте минимальную версию. Проверка версии не должна зависеть от запиненного соединения, иначе старые сборки так и не увидят предложение обновиться.
Переключите сервер на резервный ключ, затем сгенерируйте новый резервный и выпустите его пин в следующем релизе, чтобы в поле всегда было два пина.
Для скомпрометированного ключа процедура та же, только немедленно: поэтому резервный ключ и держат офлайн.
Страховка
Отправляйте ошибки пиннинга на отдельный незапиненный эндпоинт, чтобы ошибка была видна через минуты, а не в отзывах.
Удалённый флаг «выключить пиннинг» становится бэкдором для того, кто контролирует сеть, если только конфиг не подписан ключом, встроенным в приложение.
Оба позволяют серверу спросить Apple о том, кто к нему обращается. DeviceCheck хранит для вашей команды два бита на устройство, которые переживают переустановку; App Attest доказывает, что запрос подписан настоящим экземпляром вашего приложения на реальном устройстве Apple.
DeviceCheck
DCDevice.current.generateToken() возвращает короткоживущий токен. Сервер отправляет его в Apple, чтобы прочитать или установить два бита и метку времени для этого устройства.
Типичное применение: «это устройство уже брало пробный период» или «помечено как мошенническое», причём без идентификатора, который пользователь сбросит, удалив приложение.
О подлинности приложения он ничего не говорит.
App Attest
Приложение создаёт ключ в Secure Enclave через generateKey(), а attestKey просит Apple заверить его вместе с хешем одноразового challenge от сервера.
Сервер проверяет цепочку до корневого сертификата App Attest от Apple, ваш Team ID и bundle ID, challenge и счётчик, затем сохраняет публичный ключ для этой установки.
Дальше каждый чувствительный запрос несёт generateAssertion по запросу и свежему challenge; сервер проверяет подпись и то, что счётчик вырос; это защищает от повторов.
Ограничения: isSupported равен false в симуляторе и некоторых расширениях, аттестация ограничена по частоте, поэтому делайте её один раз на установку и раскатывайте постепенно, а entitlement com.apple.developer.devicecheck.appattest-environment выбирает development или production.
Чего не делает ни один
Они не аутентифицируют пользователя, а на джейлбрейкнутом устройстве можно запустить ваше настоящее приложение и управлять им. Используйте их как сигналы для решений на сервере, рядом с сессией пользователя.
Держите настоящие учётные данные за привязанными к устройству ключами Secure Enclave, а судьёй сделайте сервер: Face ID открывает ключ через SecAccessControl, PIN проверяет сервер с лимитом попыток, а сессией служит короткоживущий токен, который хранится только в памяти.
Регистрация
После полного входа (пароль плюс SMS или другой фактор) создайте два ключа в анклаве: ключ устройства только с .privateKeyUsage и биометрический ключ с .biometryCurrentSet. Зарегистрируйте оба публичных ключа вместе с аттестацией App Attest, в clientDataHash которой входят challenge сервера и оба публичных ключа: так сервер знает, что их предъявил настоящий экземпляр приложения; Apple сертифицирует только ключ App Attest, а не ключи Secure Enclave.
Никогда не храните PIN на устройстве. У 6-значного PIN миллион вариантов: если он что-то расшифровывает локально, скопированный файл перебирается за секунды. Сервер хранит медленный хеш PIN, привязанный к этому устройству.
Разблокировка
Face ID: биометрический ключ подписывает challenge сервера, и сервер выдаёт короткоживущий access-токен. После регистрации нового лица ключ перестаёт работать: переходите на PIN, затем предложите снова включить Face ID.
PIN: приложение отправляет PIN вместе с подписью ключа устройства. Сервер считает неудачи по устройству и после пяти отзывает привязку, что требует полного входа.
Сессия и автоблокировка
Access-токен живёт только в памяти, минуты; на диске нет долгоживущего токена, который открывает аккаунт без одного из двух доказательств.
Запоминайте время, когда сцена уходит в фон; если по возвращении прошло больше 60 секунд или процесс был убит, сбросьте токен из памяти и покажите экран блокировки. Настоящим пределом служит время жизни токена на сервере, потому что пользователь может перевести часы устройства.
Закрывайте UI до снапшота для переключателя приложений, а «удалить это устройство» должно стирать оба ключа и все элементы Keychain, пока сервер отзывает привязку.