На вебе у вас есть navigator.credentials.create() — и весь браузерный стек WebAuthn под ним. На мобильном приложении такого вызова нет: там нет браузера, который держит origin и подставляет RP ID. Вместо него — платформенный API: AuthenticationServices на iOS и Credential Manager на Android. Многие считают, что «мобильные passkeys — это просто тот же WebAuthn в WebView»; это неверно в двух важных местах. Во-первых, серверная ceremony остаётся той же WebAuthn-верификацией (обещание второй статьи: те же проверки, тот же challenge, тот же конвейер), — меняется только то, кто и как формирует запрос на клиенте. Во-вторых, origin на мобиле привязан не к домену браузера, а к ассоциации приложения и домена — файлам apple-app-site-association (iOS) и assetlinks.json (Android), о которых №1 обещала «разбор в четвёртой статье». Эта статья — четвёртая в серии; первые три дают механику, PHP/JS-код и WordPress-экосистему.
Главный честный тезис: мобильный passkey ломает наивное «приватный ключ никуда не уходит» сразу с двух сторон. С одной — на платформе API не браузерный, и «куда уходит ключ» определяет не вкладка, а менеджер учётных данных системы (iCloud Keychain / Google Password Manager), который синхронизирует ключ через аккаунт; synced-ключ физически хранится не в одном чипе. С другой — доступ приложения к этому ключу открывает не URL, а криптографически заявленная ассоциация app↔domain, и эти файлы ассоциации становятся частью поверхности атаки наравне с allowlist origin. Безопасность мобильной церемонии = серверная WebAuthn-проверка (не меняется) плюс корректно настроенная ассоциация домена (новая, платформенная).
Оговорка о проверке кода. Ниже — учебные скелеты на Swift и Kotlin с абстрактными идентификаторами (
example.test, бандлcom.example.app), чтобы показать форму requests и порядок вызовов. Компилятора Swift/Kotlin в этой среде нет, а живая церемония требует реального устройства с биометрией/блокировкой экрана — в CI её тоже нет. Баланс скобок проверял косвенно, поведение платформенного API проверяется только на настоящем телефоне с настоящими ключами. Версии платформ и имена API (iOS 18ASAuthorizationProvider, Android API 28+) снимайте из документации в день внедрения — они движутся.
Часть I. Платформенный API вместо браузера
Ключевое различие, из которого растут все детали ниже. На вебе origin (RP ID + фреймворк безопасности) держит браузер: он сам подставляет RP ID, сам сверяет origin и сам шлёт вызов в аутентификатор. В нативном приложении браузера нет, и эту роль берёт на себя платформа — у каждой свой API и свой поставщик ключей.
iOS — AuthenticationServices. Регистрация и вход идут через контроллер авторизации. Скелет регистрации:
import AuthenticationServices
// провайдер платформенного ключа (iCloud Keychain / сторонний менеджер на iOS 18+)
let rpID = "example.test"
let userID = Data(userHandleBytes) // opaque, НЕ email
let user = ASAuthorizationPlatformPublicKeyCredentialDescriptor(
identifier: userID, name: "Alex", displayName: "Alex"
)
let challenge = serverChallengeBytes // тот же одноразовый ≥16 байт из Part III
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: rpID
)
let req = provider.createCredentialRegistrationRequest(
challenge: challenge, name: user.name, userID: user.userID, userHandle: user.userHandle
)
// attestationPreference = .none для публичного сайта (см. №1)
req.authenticatorAttachment = .platform // платформенный (не roaming/внешний ключ)
req.reserveUserVerification = true // требовать Face ID/Touch ID/passcode
let controller = ASAuthorizationController(authorizationRequests: [req])
controller.delegate = self // результат придёт в didComplete с PublicKeyCredential
controller.presentationContextProvider = self
controller.performRequests()
Ответ придёт в func authorizationController(controller:didCompleteWithAuthorization:) — там credential.rawID и credential.clientDataJSON/attestationObject, которые вы отдаёте на сервер в точности теми же полями, что на вебе (см. Часть III). На вход — createCredentialAssertionRequest(challenge:) вместо registration.
Важно про iOS 16→18: платформенный провайдер и passkey-автозаполнение появились в iOS 16 (сентябрь 2022). В iOS 18 Apple добавила универсальный ASAuthorizationProvider, чтобы приложение могло обратиться к сторонним менеджерам (1Password, Bitwarden и т. п.), а не только к iCloud Keychain — раньше passkey в приложении на iOS жил фактически только в iCloud. Переснимайте имена и доступность по доке Apple в день внедрения.
Android — Credential Manager. Jetpack-API (androidx.credentials), построенный поверх WebAuthn. Он заменяет устаревшие пути (локальные FIDO2-API) и работает на Android 9 (API 28) и выше (по доке Android Developers на момент написания). Регистрация:
import androidx.credentials.*
import androidx.credentials.exceptions.*
val manager = CredentialManager.create(context)
// requestJson — creation options от сервера, той же формы, что WebAuthn-создание
val createReq = CreatePublicKeyCredentialRequest(requestJson = serverCreationOptionsJson)
try {
val response = manager.createCredentialAsync(
activityContext, // Activity-контекст обязателен для UI промпта
createReq
)
sendToServer(response.requestJson) // PublicKeyCredential-материал → серверная верификация
} catch (e: CreateCredentialException) {
handleFailure(e) // см. Часть V: отдельная обработка отмены/таймаута
}
Вход — GetPublicKeyCredentialRequest (usernameless допускает отсутствие allowCredentials; сервер определяет аккаунт по userHandle + credentialID, как описано в №2). requestJson/response.requestJson здесь — JSON в формате WebAuthn: платформа не выдумывает новый протокол, она переносит тот же WebAuthn-контракт в нативный вызов, но с другим транспортом и другим origin- скоупом (Часть II).
Что общего и что разного. Общее: и там, и там материал ответа — стандартный WebAuthn-объект; сервер не обязан знать, что клиент был мобильным. Разное: браузерный navigator.credentials исчезает, вместо него платформенный промпт (Face ID/скан отпечатка/блокировка экрана), и origin- скоуп переезжает в файлы ассоциации (часть II). Не тратьте силы на «как вызвать WebAuthn в WebView» как основной путь: для нативного UX (в т. ч. автозаполнение и интеграция с системным менеджером) нужен именно платформенный API; WebView-обёрка — отдельный сценарий, не замена.
Часть II. Origin на мобиле — это ассоциация, а не домен браузера
Это то, что №1 обещала разобрать здесь: «у мобильных приложений origin другой». На вебе аутентификатор отказывается подписывать за origin, не совпадающий с RP ID вкладки. На мобиле вкладки нет, и привязка «это приложение имеет право на ключи этого домена» делается вне кода приложения — файлами ассоциации, которые хостит ваш домен.
iOS — apple-app-site-association (AASA), сервис webcredentials. Apple переводит native-origin в форму https://<RP_ID> и проверяет, что приложение заявлено для этого домена в файле .well-known/apple-app-site-association. Минимальная форма:
{
"webcredentials": {
"apps": ["TEAMID.com.example.app"]
}
}
Элемент apps — это TeamID, точка, bundle ID приложения. Соответствующая сторона — capability Associated Domains с доменом webcredentials:example.test, которую вы прописываете в entitlements приложения: без неё AASA не сработает. Apple в новых версиях iOS вынесла AASA на свой CDN (файл на домене + ссылка из App Store-метаданных), поэтому «положил файл и забыл» — уже не вся механика; сверяйте актуальную схему по доке Apple.
Android — assetlinks.json, Digital Asset Links. Android привязывает origin к паре package name + подпись приложения (SHA-256 от cert), заявленной для домена в .well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"certificates": [{
"fingerprint": ["<SHA256-отпечаток-signing-cert-app>"]
}]
}
}]
WebAuthn-ассоциации для passkeys на Android живут через Digital Asset Links: файл обязан отдаваться по HTTPS, с корректным Content-Type, по пути .well-known/assetlinks.json на точном хосте RP ID (без редиректов, без поддоменов-«примерно»). Отпечаток — именно signing-сертификат релизной сборки: самый частый баг — протестировали с debug-подписью, в стору уехал релизный ключ, и ассоциация «тихо» перестала совпадать: приложение теряет доступ к passkey, а сервер даже не узнаёт, что origin «неверный» — платформа просто не предложит ключ.
Почему это безопасность, а не конфигурация-для-галочки. Файлы ассоциации — ваш контроль над тем, какие приложения могут использовать ключи вашего домена. Это ровно тот же класс, что allowlist origin в №2: если злоумышленник может отредактировать AASA/assetlinks (утёкший доступ к хостингу .well-known/, CI, CDN) — он заявляет своё приложение к вашему домену и получает право на ваши credential-церемонии. Поэтому:
- ассоциации под тем же контролем изменений и аудита, что и
originallowlist (двухсторонняя: и приложение через entitlements, и домен через файл); - SHA-256 отпечаток релиза сверяется при каждой ротации signing-ключа (App Signing, переход на новый keystore);
delegate_permission(Android) даёт доступ к ключам и встроенному, и сторонним менеджерам-делегатам (Google Password Manager как делегат) — это расширяет UX, но и расширяет то, что вы контролируете файлом;- ошибки в этих файлах не видны с сервера: сервер получает корректно оформленный ответ от «правильного» origin — просто origin-скоуп решила платформа по ассоциации, и если она промолчала, у пользователя просто «нет passkey».
Часть III. Ceremony-эндпоинты те же — сервер не узнаёт, что клиент мобильный
Здесь выполняется обещание №2 «те же ceremony переносятся на мобильные API». Разница только в том, кто формирует запрос: на вебе это navigator.credentials, на мобиле — платформенный API. То, что он отдаёт, — стандартный материал, и сервер проверяет его по тому же конвейеру:
- регистрация:
type === "webauthn.create", challenge равен непротухшему выданному, origin (для мобилы — тот, что вывела платформа из ассоциации, т. е.https://<RP_ID>на iOS /android:apk-key-hash:<hash>на Android) входит в allowlist, RP-ID-hash совпал, UP (+UV по политике), алгоритм ключа разрешён, credential привязан к аккаунту из серверного состояния; - вход:
type === "webauthn.get", challenge, origin, RP-ID-hash, credential существует и принадлежит тому аккаунту, который определила проверенная церемония (usernameless — поuserHandle+credentialID), подписьauthenticatorData || SHA-256(clientDataJSON)валидна под хранимым публичным ключом; сессия — только после всех проверок, с ротацией id.
Практическое следствие для сервера (PHP-слой из №2, тот же web-auth/webauthn-framework или аналог): в expected_origins добавьте мобильные origins, иначе легитимный мобильный ответ упадёт на проверке origin. На вебе origin — https://example.test; на iOS нативное приложение того же RP ID придёт как https://example.test (Apple нормализует RP ID в HTTPS-origin), на Android — как android:apk-key-hash:<hash>. Заранее вычислите и внесите эти значения; OWASP прямо требует, чтобы сервер сверял client-data origin с ожидаемым и для нативных сценариев.
<?php
// sketch: мобильные origins в тот же allowlist, что и браузерные. НЕ прод — форма решения.
$expectedOrigins = [
'https://example.test', // браузер (и iOS native — Apple даёт https+RP ID)
'android:apk-key-hash:' . $androidKeyHash, // вычислите из релизного signing-сертификата
];
$cc = $securityContextFactory->create(
rpId: 'example.test',
origin: $expectedOrigins, // точное совпадение, не префикс (см. №2)
challenge: $state['challenge']
);
// дальше — тот же верификатор, что для web-церемонии: ничего WebAuthn-специфично-мобильного нет.Вывод по части: не пишите «мобильную версию верификации». Единственные две вещи, которые меняются на сервере, — (1) набор ожидаемых origins в allowlist и (2) то, что challenge/user-handle вы всё так же генерите и привязываете сами (см. Часть III №2). CBOR/COSE/подпись по-прежнему берёт поддерживаемый слой — мобильный клиент не «проще», он просто другой транспорт.
Часть IV. Synced vs device-bound: почему «ключ никуда не уходит» на мобиле — полуправда
Первая статья ввела различие synced vs device-bound. На мобильных оно перестает быть абстракцией и становится вопросом «где физически лежит приватный ключ и что его защищает».
- iCloud Keychain (iOS). Passkey, созданный платформенным провайдером, синхронизируется между Apple-устройствами одного Apple ID. Значит «приватный ключ не покидает чип» в буквальном смысле неверно: ключ (обёрнутый) уходит в iCloud и восстанавливается на другом устройстве. Защита этого пути — аккаунт Apple, а не отдельный «у меня только этот телефон».
- Google Password Manager (Android). Passkey хранится у поставщика учётных данных (Google PM) и синхронизируется между Android-устройствами (и, с недавних версий Chrome, между Android и desktop Chrome). Снова — не чип одного устройства, а облако под аккаунтом Google.
Что это меняет практически:
- Единый passkey на все устройства экосистемы = огромный UX-плюс (купил новый телефон — ключ уже есть) и тот же риск, что мы разбирали в №1: риск консолидируется на аккаунт синхронизации. Компрометация Apple ID / Google-аккаунта открывает passkeys всех ваших сайтов в этой экосистеме — поэтому «passkey = фишинг-побеждён» на synced-ключе не абсолютен: сам вход фишингом не украдёшь, но само хранилище живёт под аккаунтом, который можно угнать. NIST не допускает synced-ключей для высшего уровня assurance (AAL3) — ровно по этой причине.
- device-bound остаётся. Roaming-ключ (YubiKey) и некоторые платформенные настройки дают ключ, который не синхронизируется и привязан к конкретному аутентикатору — но на мобиле дефолт как раз synced, и об этом надо говорить пользователю явно, а не строить защиту на молчаливом «ключ в железе».
- Cross-platform синхронизация — не «из коробки». iCloud-ключи не уезжают на Android и наоборот; FIDO Credential Exchange (CX) Protocol — как раз работа о переносе passkeys между экосистемами/менеджерами (Apple↔Google↔1Password/Bitwarden), и она незавершена/не везде — переснимайте статус. Не обещайте пользователю «перенос одним тапом между iOS и Android», пока CX не в тех клиентах, что у вас реально.
Вывод части. На мобиле «приватный ключ никуда не уходит» правильнее переформулировать как «приватный ключ не уходит на сервер и не уходит в виде секрета, но может синхронно лежать в облаке системного менеджера под защитой аккаунта». Для выбора архитектуры это означает: ** synced = удобно и приемлемо для большинства; device-bound / roaming = когда надо, чтобы один украденный аккаунт синхронизации не открыл всё.**
Часть V. Cross-device, автозаполнение и платформенные UX-ловушки
Вход «с телефона на компьютере» (cross-device). Web-страница на десктопе показывает QR; телефон сканирует и делает WebAuthn-церемонию, подпись уходит на сервер. На мобиле это значит: ваш телефонный passkey служит для десктопного входа тоже. Протокол (CTAP2/hybrid) обеспечивает привязку, а не «просто введите код». Держите в голове: QR — часть того же скоупа RP ID (ключ все равно не подпишет за чужой origin), так что фишинг-устойчивость не теряется — но UI-ошибка «попросил код вместо скана QR» часто и есть точка, где AitM проскакивает.
Автозаполнение и «offer-at-login». iOS passkey-автозаполнение (появилось вместе с платформенным API в iOS 16) позволяет предложить ключ прямо в поле логина без перехода в отдельный экран — это мобильный аналог mediation:'conditional' из №2. На Android аналогичную роль играют интеграция Credential Manager с автозаполнением. Это то, что заметно поднимает долю «добавивших ключ» (тот же offer-at-login, что в №2 шаг 2).
Платформенные ловушки обработки ошибок. NotAllowedError/отмена промпта ≠ «ключ не подошёл». На Swift это ASAuthorizationError.canceled; на Android — исключения CreateCredentialException/GetCredentialException. Смешивать «человек отменил биометрию» и «сервер отверг подпись» в один generic-бренд — значит и сломать UX, и (см. №2) случайно скатиться в молча-показанный пароль. Разводите явно: отмена → «пробуем снова», провал верификации → политика fallback, и только по действию пользователя.
Что проверить перед релизом (сжатый чек-лист именно этой статьи):
- native-origins добавлены в `expected_origins` сервера (iOS `https://<RP_ID>`, Android `android:apk-key-hash:<hash>`), точное совпадение;
- AASA `.well-known/apple-app-site-association` с `webcredentials.apps = TeamID.bundleID`, и entitlement `webcredentials:` на том же домене;
- `assetlinks.json` по HTTPS, точный хост, без редиректов, **релизный** SHA-256 подписи; процесс пересверки при ротации signing-ключа;
- `reserveUserVerification`/UV-требование выставлено, когда заявляем passkey как мультифактор (UP≠UV из №1);
- userHandle opaque, не email; привязка credential к аккаунту из серверного состояния;
- challenge — тот же ≥16 байт, одноразовый, короткий TTL (мобильный промпт тоже может зависнуть — см. «начать заново»);
- отмена/таймаут промпта обработаны отдельно от отказа верификации; пароль/OTP не показывается молча (№2 fallback);
- сервер не различает ошибки «нет такого аккаунта/ключа» (enumeration), rate-limit без дешёвого локаута;
- учёт synced vs device-bound: пользователю понятно, где живёт ключ и что его защищает (аккаунт синхронизации vs устройство);
- если обещаете перенос между экосистемами — проверен статус FIDO Credential Exchange у реально используемых клиентов;
- тест на настоящем устройстве: success и cancel, и wrong-key; `php -l`/рантайм — локально.
Инструменты под задачу
Разобрать, почему «на телефоне passkey не предлагается» (чаще всего — тихо несовпавшая ассоциация в assetlinks.json/AASA или debug-отпечаток вместо релизного), — типичная задача для чата-ассистента для кода: покажите свой assetlinks.json/AASA и Swift/Kotlin-скетч, и спросите, совпадает ли origin-скоуп с тем, что сверяет сервер. Рядом держите Проверку HTTP-заголовков — .well-known/* отдаются по HTTPS, без редиректов, с Content-Type, как требует платформенная валидация, и это же проверка SSL-сертификата: обе ассоциации и весь WebAuthn живут только в secure-контексте.
Чек-лист граблей
- Ищете
navigator.credentialsв мобильном приложении. Его нет; тамAuthenticationServices(iOS) /Credential Manager(Android), а WebView-обёрка — не основной путь. - Считаете, что мобильная церемония = отдельная «мобильная верификация» на сервере. Материал стандартный WebAuthn; сервер проверяет тем же конвейером, меняется только allowlist origins.
- Забыли native-origin в
expected_origins. Легитимный мобильный ответ падает на проверке origin, хотя «в браузере всё работало». - assetlinks.json с debug-отпечатком или с редиректом. В стору уехал релизный ключ — ассоциация тихо перестала совпадать, «passkey пропал» без ошибки на сервере.
- AASA положил, но не прописал
webcredentials:в entitlements / не сверил TeamID.bundleID. Без обеих сторон ассоциация не действует; iOS дополнительно выносит AASA на свой CDN — «просто файл» уже не вся схема. - Не пересверяете SHA-256 при ротации signing-ключа. App Signing / новый keystore ломают привязку origin.
- Считаете synced-ключ «ключом в железе». iCloud Keychain / Google PM синхронизируют через аккаунт; риск консолидируется на аккаунт синхронизации (NIST не пускает synced в AAL3).
- Обещаете перенос passkey между iOS↔Android «из коробки». iCloud↔Google не синхронизируются; FIDO Credential Exchange ещё не везде.
- Смешиваете «пользователь отменил биометрию» и «подпись не прошла». Отмена промпта — не отказ аутентификации; generic-обработка роняет в молча-показанный пароль.
- Требуете «только устройство» без recovery. Потеря synced-устройства = зависимость от аккаунта; потеря device-bound = потерянный доступ (финальная пятая статья — про recovery).
- Долгий challenge «под мобильный промпт». Живая церемония всё равно секунды-минуты; TTL не раздувайте, дайте «начать заново».
- Тестируете только на эмуляторе без биометрии и без success/cancel/wrong-key. Поведение платформенного API проверяется только на настоящем телефоне с настоящим ключом; компилятор Swift/Kotlin и CI-аутентификатор здесь недоступны.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
