Как работают passkeys: WebAuthn, FIDO2 и криптография простыми словами

Passkey часто описывают одной фразой — «вход без пароля». Из-за этой фразы складывается наивная картинка: пароль никуда не делся, просто его «стал помнить» менеджер паролей или сам браузер. Это неверно, и именно поэтому из неё растут все последующие заблуждения — «значит можно фишингом перехватить», «значит ключ где-то хранится и его украдут вместе с базой», «это тот же токен, что OAuth». Эта статья — первая в серии про беспарольную аутентификацию; здесь мы разбираем механику, чтобы следующие четыре статьи (внедрение на PHP/JS, WordPress/WooCommerce, мобильные API, миграция) опирались на понятное, а не на магию.

Главный честный тезис: passkey — это не «пароль, который удобно хранить», а принципиально другая схема, в которой секрета для кражи просто нет. Приватный ключ никогда не покидает аутентификатор, сервер хранит только публичный ключ, а вход проверяется подписью под одноразовым вызовом. Поэтому фишинговая страница технически не может «выманить» то, чего в протоколе нет — устойчивость к фишингу здесь структурная, а не «потому что пароль длинный». И ровно по той же причине ломается привычное мышление о восстановлении доступа: нет секрета, который можно сбросить письмом, — есть ключ, который либо есть на устройстве, либо его надо перевыпустить (об этом — пятая статья).


Часть I. Вместо общего секрета — пара ключей

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

Passkey устроен иначе. При регистрации создаётся асимметричная пара — приватный и публичный ключи (алгоритм выбирает сервер, в практике это эллиптические кривые, чаще всего ECDSA P-256; алгоритмы меняются — сверяйте с библиотекой):

  • Приватный ключ генерируется и остаётся внутри аутентификатора — чипа телефона, ноутбука (platform-аутентификатор) или отдельного устройства (roaming-ключ, например YubiKey). Он оттуда не выходит — ни на сервер, ни в облако, ни в логи.
  • Публичный ключ отдаётся сайту (relying party, RP) и сохраняется у него. Публичный ключ ничего не может сделать сам — он нужен, чтобы проверить подпись, сделанную приватным.

Что хранит сервер после регистрации? Не секрет, а идентификатор учётной записи и публичный ключ: credential ID (ссылка на конкретный ключ), публичный ключ, opaque user handle (не email и не логин — просто внутренний ярлык аккаунта), разрешённый алгоритм, метаданные. Утечка этой базы не даёт атакующему возможности войти: там нет приватных ключей и нет паролей. Максимум — список публичных ключей, которые бесполезны без приватной половины. Это и есть первый слом интуитивного ожидания: «если взломали базу — украли мои данные для входа». В passkey-модели в базе нечего красть для входа.

Отсюда ещё одно следствие, важное для следующих статей: сбросить passkey «как пароль» нельзя. Нет значения, которое можно перегенерировать и прислать письмом. Есть только перевыпуск ключа через новую церемонию — а это уже вопрос восстановления доступа (пятая статья), а не «забыли пароль».

Часть II. Вход как challenge-response, а не «сравнить хэш»

Как тогда сайт понимает, что это вы, если он не хранит вашего секрета? Через challenge-response — схему, знакомую любому, кто видел HMAC-подпись запроса, только здесь роль «ключа HMAC» играет пара асимметричных ключей.

Регистрация и вход — две отдельные церемонии, каждая со своим набором шагов между тремя участниками (клиент-браузер, аутентификатор, сервер-RP):

РЕГИСТРАЦИЯ (registration / attestation ceremony)
  Браузер            Аутентификатор          Сервер (RP)
     |   запрос опций    |                       |
     | <-----------------|---------------------->|  сервер готовит challenge
     |  navigator.credentials.create(...)        |  (случайные ≥16 байт)
     |-----> просят подтвердить (PIN/лицо) ----->|
     |                     создаёт пару ключей    |
     |                     хранит приватный       |
     |<-- возвращает аттестацию + публичный ключ --|
     |-------------------- POST ----------------->|  сервер ПРОВЕРЯЕТ поля
     |                       |                    |  и сохраняет credential ID
     |                       |                    |  + публичный ключ
ВХОД (authentication / assertion ceremony)
  Браузер            Аутентификатор          Сервер (RP)
     |<-----------------|---------------------->|  сервер готовит СВЕЖИЙ challenge
     |  navigator.credentials.get(...)          |
     |-----> UP/UV, подпись challenge+контекст ->|
     |<-- возвращает assertion (подпись) --------|
     |-------------------- POST ----------------->|  сервер проверяет подпись
     |                       |                    |  хранимым публичным ключом
     |                       |                    |  → сессия

Ключевое слово — свежий. Сервер каждый раз выдаёт новый случайный вызов (минимум 16 криптографически случайных байт, по требованиям спецификации), одноразовый и с коротким сроком жизни. Аутентификатор подписывает не «ваш пароль», а связку «этот challenge + контекст церемонии» (тип операции, origin, RP ID, счётчик). На сервере подпись проверяется публичным ключом. Практические следствия:

  • Нельзя повторить (replay). Прошлый перехваченный ответ не годится: challenge был другим и протух. Именно поэтому «украсть хэш и отправить снова» тут не работает — отправляемое значение каждый раз уникально.
  • Срок жизни важен в UX. Challenge живёт минуты. В реальных реализациях pending-церемония закрывается по таймауту (например, провайдер WebAuthn для Two Factor в версии 2.6.2 истекает ожидание за ~10 минут и перезапускает церемонию). Если пользователь «завис» на подтверждении — нужен внятный «истекло, начать заново», а не тихий тупик. (Это же мы разбираем в третьей статье про WordPress.)
  • Сервер проверяет много полей, не только подпись. Правильная верификация требует сверить тип (webauthn.create/webauthn.get), точное совпадение challenge, origin из allowlist, хэш RP ID, флаги UP/UV, принадлежность credential ID аккаунту и саму подпись. Пропуск любой проверки — дыра; поэтому стандарт прямо советует не писать CBOR/COSE/верификацию вручную, а брать поддерживаемую библиотеку. Это мостик ко второй статье (PHP/JS): дорогая часть внедрения — не браузер, а серверная верификация подписи.

Часть III. Почему фишинг не проходит: RP ID, а не «магия шифрования»

Здесь главное заблуждение: «если всё зашифровано, значит перехватить нельзя». Шифрование канала (HTTPS) к делу не относится — оно защищает передачу, но фишинг работает до передачи: пользователь сам вводит секрет на поддельном сайте. Passkey устойчив к фишингу по другой причине — привязкой к идентификатору relying party.

Каждый passkey создаётся под конкретный RP ID — стабильный домен сайта (например example.test). Приватный ключ при подписи включает в ответ хэш RP ID и origin церемонии. Когда вы заходите на настоящий https://example.test, браузер сообщает этот RP ID, аутентификатор подписывает связку с ним, сервер сверяет хэш RP ID и origin со своим allowlist’ом — сходится, пускает.

Теперь фишинг: злоумышленник делает https://examp1e.test. Это другой RP ID. Церемония на поддельном домене даст подпись, связанную с examp1e.test, а не с настоящим сайтом; корректная проверка на настоящем сервере её отвергнет. А поддельный сервер физически не сможет получить приватный ключ настоящего аутентификатора для настоящего RP ID — аутентификатор не подпишет вызов от чужого origin тем ключом, что выпущен для вашего. То есть фишинговая страница не «расшифровывает» ничего — она просто оказывается вне области действия ключа. Устойчивость структурная: она следует из того, что ключ скоупится к домену, а не из длины секрета.

Отсюда два практических правила, которые пригодятся во всех последующих статьях:

  • RP ID выбирают узко и стабильно. Берут самый узкий домен, покрывающий приложение; не берут родительский домен «чтобы поделиться ключами между несвязанными поддоменами» — слишком широкий RP ID расширяет поверхность: компромисс любого origin в этом скоупе влияет на безопасность. Сервер использует один и тот же ожидаемый RP ID и на регистрации, и на входе, и держит явный allowlist origin (точное совпадение, включая схему/хост/порт, — никогда не паттерн и никогда не из недоверенного заголовка запроса).
  • У мобильных приложений origin другой. Native-API Apple сообщает HTTPS-origin из RP ID (проверяется через apple-app-site-association), Android — строку вида android:apk-key-hash:<hash> из сертификата приложения (через assetlinks.json). Оба файла ассоциации — часть поверхности атаки и требуют того же контроля изменений, что allowlist. (Разбор платформенных API — четвёртая статья.)

Важная граница честности: всё выше держится только при корректной серверной верификации. Если сервер не сверяет origin/RP ID или молча откатывается на пароль при неудачной церемонии — фишинг обходит не криптографию, а ошибку реализации. OWASP прямо формулирует: AitM-фишинг (adversary-in-the-middle, «человек посередине») не ломает правильно проверенную passkey-церемонию; он уводит пользователя на тот fallback, который всё ещё включён. Значит, «не давай неудачному входу по passkey тихо падать на OTP/код по почте» — это не мелочь, а условие, при котором весь смысл схемы сохраняется.

Часть IV. UP ≠ UV: касание и узнавание — разные вещи

Наивное представление: «passkey = биометрия, значит это автоматически два фактора». Не так. В протоколе различают два независимых сигнала аутентификатора:

  • User Presence (UP) — «человек взаимодействовал с аутентификатором»: нажал кнопку на USB-ключе, тронул сенсор. Доказывает присутствие, но не личность.
  • User Verification (UV) — «аутентификатор локально проверил человека»: PIN, отпечаток, лицо. Проверка происходит внутри устройства; на сервер не уходит ни образец отпечатка, ни биометрия — приходит только бит флага UV.

Следствие, о котором забывают: passkey сам по себе не является мультифактором только потому, что устройство умеет UV. Чтобы считывать ключ как «второй фактор», сервер должен явно запросить UV и проверить вернувшийся флаг. Запросили только presence — получили однофакторную possession-проверку. Это отдельная настройка политики, а не автоматика. (Пригодится во второй статье про внедрение и в пятой про метрики.)

И ещё одна честная граница, которую редко проговаривают: безопасность аккаунта ограничена самым слабым путём вокруг ключа, а не самим ключом. Сам по себе passkey не защищает от того, что:

  • скомпрометированная уже авторизованная сессия добавит ключ атакующего (поэтому перед добавлением/удалением passkey нужна недавняя повторная аутентификация);
  • небезопасное восстановление обойдёт passkey (письмо/SMS-рекавери, которое молча пускает сильнее политики);
  • сервер ошибочно свяжет credential с чужим аккаунтом (миграции БД и админ-правки маппинга требуют аудита);
  • скомпрометировано устройство или аккаунт синхронизации.

То есть «поставил passkey — аккаунт защищён» верно лишь настолько, насколько защищены регистрация ключа, управление им и восстановление. Криптография крепкая; боль смещается в процессы вокруг неё — и это ровно тема четвёртой и пятой статей.

Часть V. Synced против device-bound: где живёт ключ и чем это дорого

«Приватный ключ не покидает устройство» — правда, но с важным уточнением, которое ломает вторую наивную картинку («ключ гарантированно никуда не уезжает»). Различают два класса passkey:

  • Device-bound (привязанный к устройству). Ключ живёт в одном аутентификаторе и не копируется. Максимально предсказуемая модель: потеряли носитель — потеряли этот ключ (нужен план замены/второй ключ). Типично для аппаратных roaming-ключей и политик enterprise.
  • Synced (синхронизируемый). Ключ доступен на нескольких устройствах через credential provider — iCloud Keychain, Google Password Manager, менеджер паролей браузера. Удобство реальное: зарегистрировали на телефоне — работает на ноутбуке. Технически ключ при синхронизации шифруется на стороне устройства до выгрузки, но единственная точка отказа консолидируется: теперь критичен не «ключ на чипе», а аккаунт синхронизации (Apple ID / Google). Компромисс этого аккаунта открывает passkey, даже если сами они остаются устойчивыми к утечкам базы сайта и фишингу. Плюс: синхронизируемые ключи не считаются подходящими для наивысших уровней уверенности (например, NIST не допускает syncable-аутентификаторы на AAL3).

Выбор — по модели угроз, а не по удобству:

  • Публичные приложения обычно должны поддерживать synced passkey (доступность важнее) и давать пользователю безопасные варианты второго ключа и восстановления.
  • Enterprise/высокоуверенные сценарии могут требовать managed device-bound ключи и аттестацию, когда операционный контроль оправдывает меньшую интероперабельность.

Отдельно про аттестацию (доказательство «какое именно устройство создало ключ»): для большинства публичных сайтов её не используют и не ограничивают пользователей списком моделей — аттестация не доказывает, что устройство останется незащищённым, и несёт риски приватности. Её включают только под документированное требование (корпоративный/регуляторный контроль происхождения ключей). Ещё одна ловушка — signature counter: счётчик подписи иногда зовут «детектором клона», но строго монотонным он не всегда (synced-ключ используется с нескольких экземпляров аутентификатора, некоторые вообще не считают). Трактуйте ненормальность счётчика как сигнал риска в связке с другими, а не как повод автоматически залочить всех.


Чек-лист граблей

  • Думаете, что passkey — это «пароль в менеджере». Секрета нет вовсе: сервер хранит публичный ключ, приватный не покидает аутентификатор.
  • Ждёте, что утечка базы = компрометация входа. В базе нет ни паролей, ни приватных ключей — только credential ID и публичные ключи.
  • Считаете, что HTTPS/шифрование = защита от фишинга. Фишинг работает до передачи; спасает скоупинг ключа к RP ID, а не канал.
  • Ставите слишком широкий RP ID (родительский домен «для поддоменов») — расширяете поверхность: любой origin в скоупе влияет на безопасность.
  • Строите origin/RP ID из заголовков запроса или паттерном. Нужен явный allowlist точных совпадений (схема/хост/порт).
  • Не сверяете на сервере type/challenge/origin/RP-ID-hash/UP/UV/принадлежность ключа. Одна пропущенная проверка — дыра; берите поддерживаемую библиотеку, не парсите CBOR/COSE руками.
  • Путаете UP и UV. Presence ≠ verification; мультифакторность passkey появляется только если вы запросили и проверили UV.
  • Молча откатываетесь на пароль/OTP при неудачной церемонии. AitM-фишинг (man-in-the-middle) побеждает не крипто, а включённый fallback; откат — только осознанный, логируемый, политикой заданный путь.
  • Режете passkey «как пароль» письмом. Нет значения для сброса — есть перевыпуск ключа; планируйте recovery отдельно.
  • Игнорируете, что synced-ключ консолидирует риск на аккаунт синхронизации. Для device-bound — наоборот, нужен план потери носителя.
  • Включаете аттестацию «для надёжности» без требования. Она не доказывает неуязвимость устройства и бьёт по приватности.
  • Лочите пользователей по non-increasing signature counter. Это сигнал риска, а не универсальный детектор клона.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.