Внедрение passkeys на PHP/JavaScript: регистрация, вход и fallback на пароль

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

Главный честный тезис: самое дорогое и самое опасное в внедрении passkeys — не вызов navigator.credentials.create() в браузере (тот отдаёт готовый объект), а серверная верификация этого объекта. Это CBOR/COSE-парсинг, сверка challenge/origin/RP-ID-hash/флагов UP-UV и проверка ECDSA-подписи — CPU-ёмкая асимметричная крипто, которую нельзя писать руками и нельзя «прикинуть на глаз»; нужен поддерживаемый серверный слой. А вторая по важности ловушка — fallback: неправильно устроенный откат на пароль тихо обнуляет всю фишинг-устойчивость, ради которой затевался passkey.

Оговорка о проверке кода. Ниже — учебный скелет на абстрактных доменах (example.test), чтобы показать форму ceremony-эндпоинтов и порядок проверок; он не «копируй и кати в прод». Реальные церемонии требуют живого аутентификатора для теста (в CI его нет — тест, которому нужен ключ, это тест, который никогда не запускается), и php -l/рантайм прогоняйте локально: в этой среде линтера PHP нет, балансировку скобок проверял косвенно, а поведение крипто-библиотеки проверяется только на настоящем браузере с ключом.


Часть I. Браузер: две функции и немного транспорта

WebAuthn в браузере — это методы navigator.credentials. Регистрация (создать ключ) и вход (подписаться под вызовом). Всё остальное — ваш транспорт: сервер выдаёт «опции» (JSON с challenge и параметрами), браузер превращает их в PublicKeyCredential, вы отдаёте результат обратно на сервер.

Минимальный скелет регистрации на фронте (абстрактно):

JSON
// helper: ArrayBuffer -> base64url (WebAuthn работает бинарно, а JSON — нет,
// поэтому все бинарные поля приходится кодировать/раскодировать).
const b64url = (buf) =>
  btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

async function registerPasskey(displayName) {
  // шаг 1: сервер готовит challenge и опции (см. Часть II)
  const optsJSON = await (await fetch('/webauthn/register/options', {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ displayName })
  })).json();

  // шаг 2: раскодируем base64url-поля обратно в бинарные ArrayBuffer
  optsJSON.challenge            = decode(optsJSON.challenge);
  optsJSON.user.id              = decode(optsJSON.user.id);
  if (optsJSON.excludeCredentials)
    optsJSON.excludeCredentials = optsJSON.excludeCredentials.map(c => ({
      ...c, id: decode(c.id), type: 'public-key'
    }));

  // шаг 3: браузер вызывает аутентификатор (просит PIN/лицо/прикосновение)
  const cred = await navigator.credentials.create({ publicKey: optsJSON });

  // шаг 4: отдаём созданный ключ на сервер для проверки и сохранения
  const payload = {
    id:        b64url(cred.rawId),
    rawId:     b64url(cred.rawId),
    type:      cred.type,
    response: {
      clientDataJSON: b64url(cred.response.clientDataJSON),
      attestationObject: b64url(cred.response.attestationObject),
    },
  };
  const r = await (await fetch('/webauthn/register/verify', {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })).json();
  return r.ok ? 'passkey добавлен' : r.error;
}

// base64url -> Uint8Array
function decode(s) {
  const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
  return Uint8Array.from([...bin].map(c => c.charCodeAt(0))).buffer;
}

И вход, он ещё короче:

JSON
async function loginWithPasskey() {
  const optsJSON = await (await fetch('/webauthn/login/options', {
    method: 'POST', credentials: 'same-origin'
  })).json();

  optsJSON.challenge = decode(optsJSON.challenge);
  optsJSON.allowCredentials = (optsJSON.allowCredentials || [])
    .map(c => ({ ...c, id: decode(c.id), type: 'public-key' }));

  // usernameless: allowCredentials можно вообще не слать (см. Часть II.4)
  const assertion = await navigator.credentials.get({ publicKey: optsJSON });

  const payload = {
    id:    b64url(assertion.rawId),
    rawId: b64url(assertion.rawId),
    type:  assertion.type,
    response: {
      clientDataJSON: b64url(assertion.response.clientDataJSON),
      authenticatorData: b64url(assertion.response.authenticatorData),
      signature: b64url(assertion.response.signature),
    },
  };
  const r = await (await fetch('/webauthn/login/verify', {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })).json();
  return r.ok ? location.assign(r.redirect || '/account') : r.error;
}

Что важно понимать про эту часть:

  • await здесь обязателен по-настоящему. navigator.credentials.create/get открывает системный промпт и ждёт действия человека (и может быть отменён, AbortError). Это асинхронная «живая» операция, а не мгновенный вызов — ловите отмену и таймауты отдельно от «ключ не подошёл».
  • Base64url — ваш ручной труд, а не браузер. WebAuthn API оперирует бинарными ArrayBuffer, а по HTTP вы везёте JSON. Каждое бинарное поле (challenge, user.id, rawId, attestationObject, clientDataJSON, …) надо закодировать на одной стороне и раскодировать на другой. Ошибка кодирования (например, забыли заменить +///=) выглядит как «аутентификатор не отвечает» — хотя всё сломал transport.
  • Браузер сам делает самое «магическое». Приватный ключ никуда не уходит, подпись ставится внутри аутентификатора, RP ID и origin браузер подставляет сам. Фронт — это тонкий транспорт; не тратьте силы на «защиту фронтенда passkeys», она не там.

Часть II. Сервер: конвейер проверок, где живёт безопасность

Браузер вернул набор бинарных полей. Теперь сервер должен убедиться, что это настоящий ответ настоящего аутентификатора на настоящий вызов от настоящего origin, а не мусор из devtools. Это не «одна openssl_verify», а список проверок. OWASP и спецификация W3C перечисляют обязательные поля; вот они на русском и по порядку:

При регистрации (/webauthn/register/verify) сервер проверяет как минимум:

  1. clientDataJSON.type === "webauthn.create" (тип церемонии — не перепутан со входом);
  2. пришедший challenge равен выданному (и ещё не использован, не истёк);
  3. origin в clientData точно входит в allowlist сервера (схема+хост+порт, не префикс, не из заголовка запроса);
  4. хэш RP ID в authenticatorData равен ожидаемому;
  5. установлен флаг UP; и UV, если вы его требовали политикой;
  6. алгоритм публичного ключа — один из тех, что вы разрешили;
  7. сам публичный ключ и credential ID структурно валидны;
  8. аттестация проверена — только если ваша политика её требует (для публичных сайтов обычно не требует);
  9. и только после всего — сохраняете credential, привязав к аккаунту из серверной записи о церемонии, а не из user.id, возвращённого клиентом.

При входе (/webauthn/login/verify) — симметрично:

  1. type === "webauthn.get";
  2. challenge равен непротухшему неиспользованному;
  3. origin в allowlist (точное совпадение);
  4. хэш RP ID совпал;
  5. credential ID существует и принадлежит тому аккаунту, который определила проверенная церемония;
  6. подпись authenticatorData || SHA-256(clientDataJSON) валидна под хранимым публичным ключом;
  7. UP (и UV, если требовали);
  8. counter/backup-флаги трактуем как сигналы риска (не универсальный «детектор клона»);
  9. сессию создаём только после успеха всех проверок и ротим её id.
PHP
<?php
// sketch: форма ceremony-эндпоинта. НЕ прод-код — иллюстрация порядка проверок.
// Реальную верификацию CBOR/COSE/подписи берёт на себя поддерживаемый слой.

function handle_login_verify(array $req): array {
    // A. доставать серверное состояние церемонии, а не доверять клиенту.
    $state = ceremony_state_consume('login', $req['session_token'] ?? ''); // одноразовый сним
    if (!$state)               return err('ceremony expired/unknown');

    // B. собрать ответ аутентификатора (бинарные поля раскодированы из base64url).
    $assertion = parse_assertion($req['raw']);

    // C. конвейер проверок ПОРЯДКА нет, но ВСЕ обязательны (см. список выше):
    //    type, challenge===$state->challenge, origin allowlist, rp-id hash,
    //    UP (+UV по политике), принадлежность credential аккаунту, подпись.
    $cc = $state->securityContext;   // expected origin + RP ID + challenge
    $data = $assertion->toAuthenticatorAssertionResponse(
        $state->requirementUserPresent,
        $state->requirementUserVerified
    );
    $pubkey = credential_public_key($assertion->getCredentialId(), $state->userId); // из БД
    $verifier->verify($cc, $data, $pubkey);  // бросает исключение при любой несовпадающей проверке

    // D. counter — сигнал риска, не автоблок (см. Часть II.5 про клонирование).
    maybe_log_counter_anomaly($assertion, $state);

    // E. СЕССИЮ — только сейчас, и ротируем id.
    $sid = session_regenerate_strict($state->userId);
    return ok(['redirect' => '/account', 'sid' => $sid]);
}

II.4. Usernameless / discoverable credentials. Обычная схема («account-first»): пользователь сначала вводит email/логин, сервер шлёт allowCredentials = credential ID этого аккаунта, аутентификатор подписывает одним из них. Usernameless («без имени пользователя вообще») — allowCredentials не шлём, аутентификатор сам находит discoverable-ключ и возвращает user handle + credential ID; сервер по ним обоим находит запись и требует, чтобы оба вели в один аккаунт (не доверяем никакому «display name» с клиента). Это ломает enumeration (сервер не подсвечивает «есть такой аккаунт») и даёт тот самый UX «нажми и войди», но требует discoverable-ключей и аккуратной привязки user handle↔аккаунт.

II.5. Signature counter — не делайте из него автозащиту. Когда ненулевой counter не вырос, это может означать клон, сбой аутентификатора или просто out-of-order обработку — а многие synced-ключи используют несколько экземпляров аутентификатора, где монотонность не гарантирована (и некоторые вообще не считают). Трактуйте аномалию как один из сигналов риска рядом с другими, а не как повод залочить всех подряд.

II.6. Не парсите CBOR/COSE и не проверяйте подпись руками. Это прямой совет и спецификации, и OWASP: самодельный парсер и самодельная ECDSA-верификация — самый частый источник дыр. Берите поддерживаемый серверный слой и держите его обновлённым, включая в тесты и успешные, и проваленные церемонии. Что есть в PHP-ландшафте (названия меняются — сверяйте репозитории в день выбора):

СлойКогда хорошЗамечание
web-auth/webauthn-framework (Spomky-Labs)зрелый PHP-стек, есть Symfony-бандл webauthn«полноценный» фреймворк; обёртки webauthn-lib
simplewebauthn (@simplewebauthn/server)если серверный слой у вас на Node, а не PHPTS-first, парный @simplewebauthn/browser; удобно в fullstack-TS
shipmonk/passkeys и подобные «runnable relying party»-примерыучебный/малый проект, чтобы посмотреть полный рабочий flow на голом PHPсравнивайте проводку с фреймворком, не копируйте вслепую

Выбор не «какой самый популярный», а «какой живёт и обновляется у вас под рукой». Библиотека не делает ваш учётный flow безопасным сама по себе: выдачу/потребление challenge, выбор expected RP ID и origins, привязку церемонии к сессии/аккаунту, enforcement UV и жизненный цикл ключей вы по-прежнему отвечаете сами.


Часть III. Challenge: сердце протокола и типичная дыра

Если из всей статьи запомнить один серверный механизм — это он. Challenge — одноразовый случайный вызов, который делает повтор Impossible. Сервер генерирует его сам (не берёт с клиента), криптографически стойким генератором, минимум 16 случайных байт, кладёт в серверное состояние и ждёт ровно этот challenge в ответе.

Что должно быть привязано к этому сниму состояния (иначе «проверить challenge» мало что даёт):

PHP
<?php
// sketch: что сервер хранит о церемонии, пока ждёт ответ браузера.
$ceremonyState = [
    'type'      => 'register',          // ИЛИ 'login' — не взаимозаменяемы
    'challenge' => random_bytes(32),    // ≥16 байт, CSPRNG; на клиент НЕ отдаём дважды одно и то же
    'user_id'   => 4242,                // аккаунт ФРОМА (сессия), не из POST-а
    'session'   => $currentSessionId,   // привязка: только эта сессия может завершить
    'rp_id'     => 'example.test',      // ожидаемый RP ID (тот же, что в creation options)
    'origins'   => ['https://example.test'], // allowlist для проверки clientDataJSON.origin
    'uv'        => true,                // требовали ли user verification
    'expires'   => time() + 120,        // короткий TTL (секунды-минуты, не часы)
    'used'      => false,               // одноразовость
];

Правила, которые ломаются чаще всего:

  • Не принимать challenge входа как challenge регистрации и наоборот. Тип церемонии (webauthn.create vs webauthn.get) обязан быть в привязке; иначе атакующий, получивший «разрешённый» challenge для входа, попытается использовать его в регистрации чужого ключа.
  • Одноразовость и в успехе, и в провале. Использовали — инвалидируем независимо от результата; иначе challenge переживает несколько попыток и окно атаки растёт.
  • TTL — короткий, и это UX-следствие. Live-церемония — секунды-минуты; человек, зависший на промпте, после истечения должен получить «начать заново», а не тупик (в реальных плагинах это окно выносят в минуты — например, провайдер WebAuthn в 2.6.2 закрывает pending-церемонии за ~10 мин и перезапускает).
  • user handle — opaque и привязан к аккаунту. Генерируйте стабильный непрозрачный идентификатор без email/имени, и не переназначайте его другому аккаунту. Это и приватность, и защита от подмены аккаунта на usernameless-входе.
  • Регистрация — чувствительное изменение аккаунта. Перед добавлением passkey к существующему аккаунту требуйте недавнюю повторную аутентификацию, не довольствуясь long-lived кукой сессии; привязывайте новый credential к аккаунту из серверного состояния (user_id ФРОМА), а не к идентификатору, пришедшему из клиента; excludeCredentials (уже зарегистрированные ключи аккаунта) ставьте для UX — но уникальность credential ID всё равно enforcing на сервере. И защищайте endpoints регистрации/входа от CSRF как обычные чувствительные формы (у вас это уже тема статьи про XSS и CSRF).

Часть IV. Fallback: нужное решение, которое легко превращает passkey в декорацию

Здесь проходит главная граница честности всей реализации. Fallback нужен: без него пользователь, потерявший/сменивший устройство, не войдёт, и вы просто создадите новый класс «запертых аккаунтов» (это полноценная тема пятой статьи). Но неправильно устроенный fallback обнуляет смысл.

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

Практические правила безопасного откатa:

  1. Неудача passkey ≠ автоматический показ пароля. Откат к другому методу — это осознанный, логируемый, политикой заданный путь, а не дефолтный catch. Пользователь сам выбирает «войти иначе», а система не подсовывает слабый метод вместо сильного без явного действия.
  2. OTP и коды «ввести руками» — не фишинг-устойчивы (это прямо фиксирует NIST SP 800-63B §3.2.5). Для аккаунтов, где passkey обязан быть гарантией, такой fallback должен быть либо выключен, либо покрыт дополнительным контролем (заморозка высокорисковых-действий после кодового входа, уведомление в доверенный канал).
  3. Универсальные сообщения об ошибке. Не различайте по ответу/таймингу «нет такого аккаунта» / «нет такого ключа» / «подпись не прошла» / «политика отвергла» — иначе делаете enumeration учётных записей. Generic ошибка + ровный тайминг + rate-limit по аккаунту и сигналу устройства (но без дешёвого DoS-локаута).
  4. Уведомление и аудит жизненного цикла. Добавление/удаление ключа, смена метода recovery — это security-события: уведомите через уже доверенный канал и запишите событие. Это ваш ранний детектор того, что скомпрометированная сессия пытается прикрепить ключ атакующего (см. часть IV.1 первой статьи: passkey не спасает от добавления ключа уже заломленной сессией — от этого спасает требование недавней аутентификации перед изменением).

Типичная неправильная и правильная формы обработки неудачи (JS-сторона):

JavaScript
// ПЛОХО: молча скатываемся на пароль — это дыра, а не «забота о UX».
try { await loginWithPasskey(); }
catch { showPasswordForm(); }   // ← AitM теперь работает как с обычным паролем

// ЛУЧШЕ: провал passkey — отдельное состояние с явным выбором и логом.
try { await loginWithPasskey(); }
catch (e) {
  if (e.name === 'NotAllowedError') {
    showRetryPasskey();         // отмена/таймаут: начать церемонию заново
  } else {
    logFailedCeremony(e);        // security-событие
    showOtherMethodsAfterUserAction(); // слабые методы — только по явному клику, не по умолчанию
  }
}

Часть V. Порядок внедрения: по кривой риска, а не big-bang

Как вкатывать, чтобы не сломать вход половине базы.

Шаг 1 — passkey как второй способ, пароль остаётся. Включаете добавление ключа в настройках аккаунта (требовать недавнюю аутентификацию перед добавлением), на входе passkey показывается как опция рядом с паролем, но парольный путь не отключён. Риск минимальный: ничего не «теряется», если у человека нет ключа. Это ровно Подход A из третьей статьи.

Шаг 2 — offer-at-login / conditional mediation. WebAuthn умеет подсказывать passkey в момент ввода логина (условное UI-предложение), так что ключ предлагается там, где пользователь уже собирается войти, а не в глубине настроек. Это заметно поднимает долю добавивших без агрессии — и это же UX-мостик к миграции (пятая статья про то, как переводить базу и что мерить: не «% включивших», а lockout-rate и долю успешных входов).

Шаг 3 — usernameless / primary только там, где есть recovery. Перевод аккаунта в «вход без пароля как основной» делаем после того, как закрыт вопрос восстановления: у пользователя уже ≥2 аутентикатора или есть надёжный recovery-путь (другой passkey, отдельный recovery-код как секрет, high-assurance верификация — не email-ссылка, если это высокорисковый аккаунт). Иначе вы на Шаге 3 создадите тикеты «потерял телефон — не могу войти» пачками. Полноценная стратегия и метрики — пятая статья серии.

Что проверить перед релизом (сжатый чек-лист именно этой статьи):

  • challenge: CSPRNG, ≥16 байт, одноразовый, TTL секунды-минуты, привязан к типу/сессии/аккаунту/origin;
  • server-верификация: полный список полей Части II (не только подпись);
  • точный allowlist origin; один и тот же expected RP ID на create и на get;
  • credential привязан к аккаунту из серверного состояния, уникальность credential ID enforcing;
  • UV требует и проверяю флаг, когда заявляю passkey как мультифактор;
  • неудачная церемония не показывает пароль/OTP по умолчанию; слабые методы — по явному действию + лог;
  • recent-auth перед добавлением/удалением ключа и сменой recovery; уведомление о lifecycle-событиях;
  • CSRF-защита ceremony-эндпоинтов + rate-limit без дешёвого локаута;
  • тест с реальными браузерами/аутентикаторами и на успех, и на провал;
  • сессию создаю только после всех проверок и ротю id;
  • поддерживаемый WebAuthn-слой стоит в зависимостях и обновляется (не самодельный CBOR/COSE).

Инструменты под задачу

Код ceremony-эндпоинта, разбор пришедшего attestationObject, поиск пропущенной проверки в верификации — типичная задача, которую удобно прогнать через чат-ассистент для кода: вставьте фрагмент PHP/JS и спросите, какие поля ответа аутентификатора вы сверяете, а какие пропустили. Рядом держите Проверку HTTP-заголовков, чтобы убедиться, что страницы регистрации/входа отдаются с Secure/SameSite-куками и строгим CSP (WebAuthn живёт только в secure-контексте и требует защиты этих страниц от инъекции скриптов — это же XSS/CSRF).


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

  • Тратите время на «защиту фронтенда passkeys». Ключ не покидает аутентификатор, RP ID/origin браузер подставляет сам; вся безопасность — серверная верификация.
  • Пишите CBOR/COSE/ECDSA-верификацию руками. Главный источник дыр; берите поддерживаемый слой и обновляйте его.
  • Проверяете только подпись, а не список полей (type/challenge/origin/RP-ID-hash/UP/UV/принадлежность ключа). Одна пропущенная проверка = обход.
  • Берёте аккаунт из user.id/display name с клиента. Привязывайте credential к аккаунту из серверного состояния церемонии; user handle opaque и не переназначается.
  • Challenge долгий, многократный или не привязан к типу/сессии/origin. Принять challenge входа как challenge регистрации — прямая дыра; инвалидируйте и в успехе, и в провале.
  • Забываете base64url-кодирование бинарных полей. Ошибка transport выглядит как «аутентификатор не отвечает».
  • Путаете UP и UV. Presence ≠ verification; мультифакторность passkey появляется только если запросили и проверили UV.
  • Молча скатываетесь на пароль/OTP при неудаче. AitM побеждает не крипто, а включённый fallback; откат — явный, логируемый, политикой заданный путь.
  • Различаете ошибки «нет аккаунта»/«нет ключа»/«подпись плохая» по ответу/таймингу. Делаете enumeration; нужен generic-ответ + rate-limit без дешёвого локаута.
  • Добавляете passkey по long-lived сессии без recent-auth. Сломанная сессия прикрепит ключ атакующего; требуйте свежую аутентификацию и уведомляйте о lifecycle-событиях.
  • Делаете passwordless-как-основной до закрытия recovery. Создаёте класс «запертых аккаунтов»; впереди — стратегия и метрики из пятой статьи.
  • Трактуете non-increasing counter как детектор клона и лочите всех. Synced-ключи ломают монотонность; counter — сигнал риска в связке.
  • Тестируете только happy-path и только в браузере без ключа. Нужны и провальные церемонии, и живой аутентификатор (в CI его нет — тест без ключа никогда не запускается); php -l/рантайм прогоняйте локально.

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