12k
All articles

Как добавить ключи доступа (passkeys) в приложение для iOS и Android

Добавьте ключи доступа в приложения для iOS и Android на Swift и Kotlin. Настройте связь с доменом, регистрацию и вход, а также серверную проверку учетных данных.

OpenReplay Team
OpenReplay Team
Как добавить ключи доступа (passkeys) в приложение для iOS и Android

Ключ доступа (passkey) — это пара из открытого и закрытого ключей, привязанная к одному домену. Приложение создаёт её через системный API учётных данных, сервер хранит только открытый ключ, а Face ID или отпечаток пальца лишь разрешают устройству воспользоваться закрытым ключом.

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

Причина почти всегда одна из трёх: связь приложения с доменом, несоответствие между идентификатором проверяющей стороны (relying party ID) на сервере и этим доменом или незаконченная логика проверки на сервере. В этом руководстве каждый из этих элементов разобран для iOS (Swift) и Android (Kotlin), а для каждого шага описана работа на сервере. Предполагается, что вы уже сделали выбор в пользу ключей доступа (компромиссы между паролями и ключами доступа разобраны в другой статье).

Ключевые выводы

  • Приложение может создать или использовать ключ доступа для домена только тогда, когда оно объявляет этот домен, а домен публикует файл с указанием приложения: apple-app-site-association с webcredentials на iOS и assetlinks.json с delegate_permission/common.get_login_creds на Android.
  • Идентификатор проверяющей стороны, который отправляет сервер, должен совпадать с доменом, где размещены оба файла связи. Если изменить его позже, все существующие ключи доступа станут непригодными.
  • Приложения, распространяемые с Play App Signing, должны указывать в assetlinks.json отпечаток ключа подписи приложения от Google. Если указать только ключ загрузки, ключи доступа будут работать в локальных сборках и отказывать при установке из магазина.
  • Решение о действительности ключа доступа принимает сервер. Прежде чем создать сессию, он проверяет одноразовый challenge, origin (включая Android-origin вида android:apk-key-hash:), хеш rpId и подпись.
  • Синхронизируемые ключи доступа следуют за аккаунтом пользователя Apple или Google, поэтому безопасность ваших учётных записей теперь отчасти зависит от безопасности их аккаунтов. Сохраните способ восстановления, не зависящий от ключа доступа.

Ключ доступа — это то же самое, что биометрическая разблокировка?

Ключ доступа — это не разблокировка по отпечатку пальца. Face ID или отпечаток лишь разрешают устройству воспользоваться закрытым ключом, который создан для одного домена и никогда не покидает устройство или синхронизируемую связку ключей пользователя. Сервер хранит соответствующий открытый ключ и проверяет подпись. Биометрических данных он никогда не видит.

Локальная биометрическая защита — это другой механизм. В этом случае приложение разблокирует сохранённый ранее токен, и у сервера нет никаких криптографических доказательств. Если вам нужно именно это, настройка описана в статье о биометрической аутентификации в React Native. Здесь мы её повторять не будем.

Связь с платформой: шаг, который отказывает без предупреждения

Идентификатор проверяющей стороны (rpId), который отправляет сервер, должен совпадать с доменом, на котором размещены оба файла связи. Операционная система проверяет обе стороны независимо: приложение указывает домен, а домен публикует файл с указанием приложения. Если ошибка хотя бы на одной стороне, окно ключа доступа так и не появится, и ни одна из платформ не сообщит приложению причину.

iOS: entitlement webcredentials и apple-app-site-association

Добавьте домен в entitlement Associated Domains с сервисом webcredentials:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>webcredentials:example.com</string>
</array>

Разместите следующий файл (без расширения) по адресу https://example.com/.well-known/apple-app-site-association. Идентификатор приложения — это Team ID и bundle ID, соединённые точкой:

{
  "webcredentials": {
    "apps": ["ABCDE12345.com.example.shop"]
  }
}

Согласно руководству Apple по поддержке связанных доменов, файл должен отдаваться по HTTPS с доверенным сертификатом, а запрос не должен перенаправляться. Поскольку у файла нет расширения, многие статические хостинги и объектные хранилища отдают его с универсальным бинарным типом содержимого, поэтому задайте тип явно:

location = /.well-known/apple-app-site-association {
    default_type application/json;
}
curl -sI https://example.com/.well-known/apple-app-site-association | grep -i content-type
# expect: content-type: application/json

Устройства не скачивают этот файл с вашего сервера. Они читают копию, которую запрашивает и кеширует CDN Apple. Согласно документации Apple, CDN запрашивает файл в течение примерно 24 часов, а установленные устройства проверяют наличие обновлённой копии примерно раз в неделю. Поэтому исправленный файл доходит до установленных приложений не сразу: сначала CDN должен запросить его повторно, а затем каждое устройство получит его при установке, обновлении или очередной еженедельной проверке. Если в момент запроса CDN файл был сломан, CDN закеширует и этот сломанный результат. Для разработки используйте webcredentials:example.com?mode=developer и включите Associated Domains Development в разделе «Настройки › Для разработчиков» на устройстве. Тогда устройство будет запрашивать файл напрямую с вашего домена. Перед релизом уберите этот суффикс, потому что релизные сборки полагаются на копию из CDN.

Android: assetlinks.json с get_login_creds

На Android ключам доступа в Credential Manager нужны Digital Asset Links на домене rpId, доступные по адресу https://example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.shop",
      "sha256_cert_fingerprints": [
        "<PLAY_APP_SIGNING_KEY_SHA256>",
        "<UPLOAD_KEY_SHA256>"
      ]
    }
  }
]

Укажите отпечаток каждого хранилища ключей, которым вы подписываете приложение:

keytool -list -v -keystore release.keystore -alias release | grep SHA256

Если приложение распространяется с Play App Signing, Google переподписывает его перед доставкой. Для установок из магазина значение имеет отпечаток ключа подписи приложения, указанный на странице App signing («Подпись приложения») в Play Console. Если указать только локальный ключ загрузки, ключи доступа будут работать в отладочных сборках и отказывать у пользователей, установивших приложение из магазина. У приложений, подключённых к квантово-устойчивой гибридной подписи Play, ключей подписи больше одного, поэтому добавьте все SHA-256-отпечатки, которые показаны на этой странице. Лишние отпечатки никакого вреда не причиняют.

Предварительные проверки

ПроверкаКак проверитьСимптом при ошибке
Content-Typecurl -sI для URL файла AASAНет окна на iOS
Отсутствие редиректовcurl -sI возвращает 200, а не 301/302Нет окна ни на одной платформе
Совпадение поддоменаФайлы размещены точно на хосте rpIdВ вебе работает, в приложении — нет
Ключ подписи PlayСравнить со страницей App signing в Play ConsoleВ отладке работает, из магазина — нет
Копия в CDN AppleЗапросить URL CDN, приведённый нижеИспользуется старый или сломанный файл

Поддомены не наследуют связь. Спецификация Digital Asset Links идентифицирует сайт по схеме, точному хосту и порту, поэтому файлы на www.example.com никак не действуют для rpId example.com. У Apple правило то же: каждому поддомену нужен собственный файл apple-app-site-association.

Оба файла можно проверить, не устанавливая сборку. Первый URL возвращает то, что хранится в CDN Apple для вашего домена (инженеры Apple используют его для диагностики; это не документированный API). Второй вызывает эндпоинт statements.list Google:

curl https://app-site-association.cdn-apple.com/a/v1/example.com

curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://example.com&relation=delegate_permission/common.get_login_creds"

Сломанная связь не порождает исключения, которое мог бы поймать ваш краш-репортер. Воспроизведение сессий (session replay) делает закономерность видимой: нажатие «Войти с ключом доступа», системное окно не появляется, затем пользователь переходит к полю пароля. После этого такие сессии можно отфильтровать по сборке и варианту подписи.

Как внедрить ключи доступа: регистрация

Регистрация ключа доступа в нативном приложении состоит из трёх частей. Сервер создаёт параметры (options) со свежим challenge и rp.id, равным связанному домену. Нативный API создаёт учётные данные. Сервер проверяет результат и сохраняет открытый ключ и идентификатор учётных данных (credential ID). Параметры сервера выглядят так:

{
  "challenge": "<base64url, random, single-use>",
  "rp": { "id": "example.com", "name": "Example Shop" },
  "user": { "id": "<base64url user handle>", "name": "ada@example.com", "displayName": "Ada" },
  "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
  "authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
  "attestation": "none",
  "excludeCredentials": []
}

На iOS запрос формирует ASAuthorizationPlatformPublicKeyCredentialProvider:

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com")
let request = provider.createCredentialRegistrationRequest(
    challenge: challenge, name: "ada@example.com", userID: userHandle)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()

// In the delegate:
if let reg = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
    api.finishRegistration(id: reg.credentialID, clientData: reg.rawClientDataJSON,
                           attestation: reg.rawAttestationObject)
}

На Android добавьте androidx.credentials:credentials и androidx.credentials:credentials-play-services-auth. Вторая зависимость обязательна на Android 13 и ниже, как поясняется в руководстве по миграции с FIDO2. Актуальную версию возьмите из примечаний к выпуску. Затем передайте JSON от сервера напрямую в CreatePublicKeyCredentialRequest:

val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
    context = activity,
    request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)

Далее сервер выполняет процедуру регистрации WebAuthn. Он проверяет challenge, origin и хеш rpId, а затем сохраняет идентификатор учётных данных, открытый ключ, счётчик подписей (sign count) и user handle. Android-приложения отправляют origin вида android:apk-key-hash:<encoded SHA 256 fingerprint>, и сервер должен принимать именно это значение как один из разрешённых origin. Добавьте по одному origin на каждый сертификат подписи.

Вход: assertion и проверка на сервере

Вход с ключом доступа устроен так же, как регистрация. Сервер выдаёт challenge, устройство подписывает его закрытым ключом, а сервер проверяет подпись с помощью сохранённого открытого ключа. Приложение никогда не решает, действителен ли ключ доступа.

let request = provider.createCredentialAssertionRequest(challenge: challenge)
// Delegate receives ASAuthorizationPlatformPublicKeyCredentialAssertion:
// credentialID, rawClientDataJSON, rawAuthenticatorData, signature, userID
val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson = optionsJson)))
val result = credentialManager.getCredential(context = activity, request = request)
api.finishSignIn((result.credential as PublicKeyCredential).authenticationResponseJson)

Следуя шагам проверки assertion, сервер:

  1. Убеждается, что challenge совпадает с одним из выданных и ещё не был использован.
  2. Сверяет origin со списком разрешённых, включая все Android-origin вида apk-key-hash.
  3. Проверяет, что rpIdHash равен SHA-256-хешу вашего rpId.
  4. Требует флаг присутствия пользователя (user present), а также флаг верификации пользователя (user verified), если этого требует ваша политика.
  5. Проверяет подпись с помощью сохранённого открытого ключа.
  6. Применяет правило счётчика подписей. Спецификация сравнивает счётчики, только если сохранённое или полученное значение отлично от нуля, поэтому не отклоняйте нулевой счётчик.

Синхронизация превращает ключи доступа в вопрос восстановления аккаунта

Синхронизируемые ключи доступа появляются на устройствах, на которых пользователь никогда не входил в ваше приложение. Ключи доступа, сохранённые в Google Менеджере паролей, следуют за аккаунтом Google на другие Android-устройства, где выполнен вход в этот аккаунт, а Связка ключей iCloud синхронизирует их между устройствами Apple пользователя. Пользователь может купить новый телефон, открыть ваше приложение и сразу войти.

Синхронизация ключей доступа имеет три последствия для восстановления аккаунта:

  • Потерян телефон, а не аккаунт. При потере устройства ключ доступа остаётся доступным на других устройствах пользователя.
  • Аккаунт платформы теперь входит в ваш периметр безопасности. Надёжность ключа доступа для вашего домена зависит от аккаунта пользователя Apple или Google, включая его двухфакторную аутентификацию и проверки при восстановлении.
  • Синхронизацией пользуются не все. Пользователи с отключённой синхронизацией связки ключей, сторонним провайдером или без аккаунта Google на устройстве по-прежнему могут потерять свои единственные учётные данные.

Разрешите несколько ключей доступа на аккаунт, добавьте экран, где пользователи могут просматривать и отзывать их, и сохраните путь восстановления через email или OTP. Если на новом устройстве вход откатывается к паролю, несмотря на синхронизированный ключ доступа, это обычно указывает на ошибку в rpId или списке разрешённых origin, а не на отказ пользователя.

Резервный вариант для старых устройств и несогласных

На старте сохраните вход по паролю или OTP наряду с ключами доступа. Нативным ключам доступа на Android нужна версия Android 9 (API level 28) или выше, а для стороннего провайдера ключей доступа — Android 14. Некоторые пользователи будут отклонять запрос независимо от устройства. Обрабатывайте NoCredentialException и ASAuthorizationError.canceled, показывая существующую форму входа, а не ошибку.

Предлагайте создать ключ доступа после успешного входа по паролю, а не навязывайте его при регистрации. Считайте старый способ входа постоянным, пока данные не покажут, что его можно отодвинуть на второй план. В статье о том, как работает беспарольный вход, объясняется, почему ключи доступа надёжнее, чем magic links и OTP. Приложения на React Native и Flutter подчиняются тем же правилам: какую бы библиотеку-мост вы ни выбрали, она вызывает те же нативные API, поэтому файлы связи и серверные проверки не меняются.

Заключение

Большинство ошибок с ключами доступа — это ошибки конфигурации, а не кода. Прежде чем писать нативный код, выберите rpId и разместите оба файла связи именно на этом домене. Проверьте их двумя командами curl, а также добавьте на сервер отпечаток Play App Signing и Android-origin. Как только обе проверки вернут ваше приложение, приведённых выше вызовов на Swift и Kotlin будет достаточно для клиентской части.

Часто задаваемые вопросы

Какая минимальная версия iOS нужна для ключей доступа в нативном приложении?

Синхронизируемым ключам доступа нужна iOS 16 или новее — в этом выпуске Apple представила ключи доступа, хранящиеся в Связке ключей iCloud. Учётные данные, созданные встроенным платформенным аутентификатором на iOS 15 или более ранней версии, никогда не становятся ключами доступа и остаются на устройстве, где были созданы. Чтобы обновить такие учётные данные, зарегистрируйте новые с тем же user handle, и iOS заменит старые учётные данные синхронизируемым ключом доступа. На Android минимальная версия — Android 9 (API level 28).

Могут ли пользователи войти в приложение с ключом доступа, созданным на моём сайте?

Да, если сайт и приложение используют один и тот же rpId. Ключ доступа привязан к rpId, а не к клиенту, поэтому одни и те же учётные данные работают в браузере и в обоих нативных приложениях. Нативные приложения на iOS сообщают origin в виде https:// плюс rpId — то же значение, что отправляет сайт. Android-приложения сообщают origin вида android:apk-key-hash, который сервер должен отдельно добавить в список разрешённых.

Можно ли тестировать ключи доступа в эмуляторе Android и симуляторе iOS?

На Android — да. Используйте образ эмулятора с Google Play (а не чистый AOSP) с API level 28 или выше, войдите в аккаунт Google и настройте блокировку экрана, потому что ключ доступа хранится в Google Менеджере паролей. Без этого создание учётных данных завершится исключением, которое выглядит как ошибка в приложении. Симулятор iOS может вести себя иначе, чем реальное устройство, поэтому финальное тестирование ключей доступа проводите на физическом iPhone.

Какие ошибки указывают на сломанную связь, а не на ошибку в коде?

На Android исключение CreatePublicKeyCredentialDomException при регистрации обычно означает, что файл assetlinks.json отсутствует или недоступен, содержит неверное имя пакета или отпечаток либо размещён на хосте, не совпадающем с rpId. На iOS сломанная связь часто проявляется как ASAuthorizationError с кодом 1001 — тем же кодом, что и при отмене пользователем. GetCredentialCancellationException на Android означает, что пользователь закрыл окно, и сообщение об ошибке в этом случае не нужно.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.