Passkey-аутентификация

Асимметричная криптография вместо паролей

Пароли — слабое звено безопасности. Их крадут фишингом, утекают из баз данных, угадывают брутфорсом. Passkey меняет подход принципиально: вместо секрета, который пользователь помнит и вводит, используется асимметричная криптография. Приватный ключ хранится на устройстве и защищён биометрией или PIN, публичный — на сервере. Результат: фишинг невозможен, утечка базы бесполезна, пользователь не запоминает ничего.

Passkey — это не очередной менеджер паролей. Это замена паролей как концепции, основанная на стандарте FIDO2/WebAuthn. Apple, Google и Microsoft поддерживают passkey с 2022–2023 годов.

Проблемы паролей

Пароли существуют с 1960-х годов и до сих пор остаются основным способом аутентификации — несмотря на то, что за это время стало очевидно: они плохо работают в реальном мире.

Повторное использование

Среднестатистический пользователь имеет десятки, а то и сотни аккаунтов. Запомнить уникальный сложный пароль для каждого невозможно. Результат предсказуем: один пароль используется на многих сервисах. Когда утекает один сервис — злоумышленники проверяют учётные данные на всех остальных (credential stuffing).

Фишинг

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

Утечки баз данных

Даже если сервис хранит пароли правильно — в виде bcrypt или Argon2 хэшей — при утечке базы злоумышленники могут перебрать слабые пароли офлайн на GPU. Хэш "password123" будет взломан за секунды.

Масштаб проблемы: база Have I Been Pwned содержит более 12 миллиардов скомпрометированных учётных данных из реальных утечек. Это не теоретическая угроза.

Усталость от паролей и слабые пароли

Требования к сложности паролей (заглавная буква, цифра, спецсимвол) не делают их безопаснее — они делают их неудобными. Пользователи приспосабливаются предсказуемо: Password1! формально удовлетворяет всем требованиям, но взламывается мгновенно.

Трение от MFA

Двухфакторная аутентификация снижает риски, но добавляет трение: нужно доставать телефон, переключаться между приложениями, вводить одноразовый код. Часть пользователей отключает MFA из-за неудобства. SMS-коды вдобавок уязвимы к SIM-swapping и перехвату.

Что такое Passkey

Passkey — это учётные данные на основе стандарта FIDO2 / WebAuthn, разработанного альянсом FIDO (Fast IDentity Online) совместно с W3C. Вместо пароля используется пара криптографических ключей.

Асимметричная криптография

При создании passkey устройство генерирует пару ключей:

При аутентификации сервер отправляет случайный вызов (challenge). Устройство подписывает его приватным ключом. Сервер проверяет подпись публичным ключом. Приватный ключ при этом никуда не передаётся.

Пользователь              Устройство                  Сервер
    |                          |                            |
    |  Вход по биометрии       |                            |
    |------------------------->|                            |
    |                          |  (приватный ключ в        |
    |                          |   Secure Enclave)          |
    |                          |                            |
    |                          |  Подписать challenge       |
    |                          |<---------------------------|
    |                          |                            |
    |                          |  Подпись(challenge)        |
    |                          |--------------------------->|
    |                          |                            |
    |                          |  Проверить подпись         |
    |                          |  публичным ключом          |
    |                          |         OK                 |
    |<----------------------------------------------- 200 --|

Привязка к домену (origin binding)

Каждый passkey привязан к конкретному домену (RP ID — Relying Party ID). Passkey, созданный для example.com, физически не может быть использован на evil-example.com. Это делает фишинг невозможным на уровне протокола, а не на уровне "пользователь должен быть внимательным".

Верификация пользователя

Перед использованием приватного ключа устройство требует подтвердить личность пользователя через:

Биометрические данные обрабатываются локально на устройстве и никогда не покидают его — ни на сервер, ни к Apple/Google.

Синхронизация между устройствами

Платформенные passkey синхронизируются через облако платформы:

Роaming vs Platform authenticators: Платформенные аутентификаторы (встроены в устройство, синхронизируются через облако) отличаются от роуминговых (физические ключи безопасности, например YubiKey). Роуминговые ключи не синхронизируются — при потере нужен резервный способ входа.

Как работает регистрация

Регистрация passkey — это создание пары ключей на устройстве и передача публичного ключа на сервер. Используется API navigator.credentials.create().

Схема регистрации

Клиент                           Сервер
  |                                  |
  |--- GET /register/start --------->|
  |                                  |
  |<-- challenge, rp, user ----------|
  |    (опции регистрации)           |
  |                                  |
  |  navigator.credentials.create()  |
  |  (пользователь проходит          |
  |   биометрию/PIN)                 |
  |                                  |
  |--- POST /register/finish ------->|
  |    publicKey, attestation        |
  |                                  |
  |    Сервер сохраняет публичный    |
  |    ключ, привязывает к userId    |
  |                                  |
  |<-- 200 OK -----------------------|

JavaScript: создание credential

const publicKeyCredentialCreationOptions = {
  // Случайный вызов от сервера — защита от replay-атак
  challenge: Uint8Array.from("random_challenge_from_server", c => c.charCodeAt(0)),

  // Информация о сервисе (Relying Party)
  rp: {
    name: "MyApp",
    // id по умолчанию = текущий домен
    // Можно задать явно: id: "myapp.com"
  },

  // Информация о пользователе
  user: {
    id: Uint8Array.from("user_id", c => c.charCodeAt(0)),
    name: "user@example.com",
    displayName: "John Doe",
  },

  // Поддерживаемые алгоритмы подписи
  // -7 = ES256 (ECDSA с SHA-256) — стандарт де-факто
  // -257 = RS256 (RSA с SHA-256) — для совместимости с Windows Hello
  pubKeyCredParams: [
    { type: "public-key", alg: -7 },
    { type: "public-key", alg: -257 },
  ],

  authenticatorSelection: {
    // "required" — обязательная верификация пользователя (биометрия/PIN)
    userVerification: "required",
    // "required" — создать resident key (discoverable credential)
    // позволяет входить без предварительного ввода email
    residentKey: "required",
  },

  timeout: 60000,

  // "none" — не требовать аттестацию аутентификатора
  // Для обычных приложений достаточно; enterprise может требовать "direct"
  attestation: "none",
};

const credential = await navigator.credentials.create({
  publicKey: publicKeyCredentialCreationOptions,
});

Ключевые параметры

Параметр Назначение Типичное значение
challenge Случайные байты от сервера, защищают от replay-атак 16–32 байт, криптографически случайные
rp.id Домен сервиса, к которому привязан passkey example.com (без протокола и пути)
user.id Уникальный идентификатор пользователя Случайный UUID, не email и не username
pubKeyCredParams Список поддерживаемых алгоритмов подписи ES256 (-7) и RS256 (-257)
userVerification Обязательность верификации пользователя required для passkey
residentKey Хранить credential на аутентификаторе (discoverable) required для passkey без username
attestation Аттестация устройства (проверка производителя) none для большинства приложений

Что возвращает credential

// credential.response содержит:
{
  clientDataJSON,      // JSON с challenge, origin, type — подписывается
  attestationObject,   // Содержит публичный ключ и данные аутентификатора
}

// credential.id — уникальный идентификатор credential
// Сохраняется на сервере, используется при аутентификации

Как работает аутентификация

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

Схема аутентификации

Клиент                           Сервер
  |                                  |
  |--- GET /auth/start ------------->|
  |    (опционально: email)          |
  |                                  |
  |<-- challenge, allowCredentials --|
  |    (список допустимых credential |
  |     для данного пользователя)    |
  |                                  |
  |  navigator.credentials.get()     |
  |  (пользователь проходит          |
  |   биометрию/PIN)                 |
  |                                  |
  |--- POST /auth/finish ----------->|
  |    assertion (подпись challenge) |
  |                                  |
  |    Сервер: найти публичный ключ  |
  |    по credential.id, проверить   |
  |    подпись, сверить challenge    |
  |                                  |
  |<-- 200 OK (сессия создана) ------|

JavaScript: подтверждение credential

const publicKeyCredentialRequestOptions = {
  // Новый случайный challenge от сервера
  challenge: Uint8Array.from("server_challenge", c => c.charCodeAt(0)),

  timeout: 60000,

  // Домен сервиса — должен совпадать с rp.id при регистрации
  rpId: "yourdomain.com",

  // Список допустимых credential для данного пользователя
  // Если пользователь ввёл email — сервер возвращает его credential IDs
  // Если нет (passwordless) — передаём пустой массив или опускаем поле
  allowCredentials: [
    {
      id: Uint8Array.from("credential_id_from_server", c => c.charCodeAt(0)),
      type: "public-key",
    },
  ],

  // Обязательная верификация пользователя
  userVerification: "required",
};

const assertion = await navigator.credentials.get({
  publicKey: publicKeyCredentialRequestOptions,
});

Что содержит assertion

// assertion.response содержит:
{
  clientDataJSON,       // JSON с challenge, origin, type
  authenticatorData,    // Метаданные: rpIdHash, флаги, счётчик подписей
  signature,            // Подпись authenticatorData + clientDataJSON
  userHandle,           // user.id из момента регистрации (если discoverable)
}

// assertion.id — идентификатор credential
// Сервер использует его для поиска соответствующего публичного ключа
Счётчик подписей (signCount): аутентификатор хранит счётчик, который увеличивается при каждой аутентификации. Сервер может проверять, что счётчик только растёт. Если пришло меньшее значение — возможно клонирование ключа. На практике платформенные аутентификаторы (Apple, Google) могут сбрасывать счётчик при восстановлении из бэкапа, поэтому большинство серверов не блокируют сессию при уменьшении счётчика, но логируют это событие.

Серверная часть

Сервер отвечает за три задачи: генерацию и хранение challenge, верификацию ответа аутентификатора, и хранение публичных ключей.

Что хранит сервер

-- Таблица credential'ов (публичных ключей)
CREATE TABLE passkey_credentials (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users(id),

  credential_id   BYTEA NOT NULL UNIQUE,   -- assertion.id от клиента
  public_key      BYTEA NOT NULL,          -- COSE-encoded публичный ключ
  sign_count      BIGINT NOT NULL DEFAULT 0,

  -- Мета-информация
  aaguid          UUID,                    -- Идентификатор модели аутентификатора
  transports      TEXT[],                  -- ["internal", "hybrid"]
  created_at      TIMESTAMPTZ DEFAULT now(),
  last_used_at    TIMESTAMPTZ
);

-- Таблица challenge'ов (TTL: ~5 минут)
CREATE TABLE webauthn_challenges (
  challenge       BYTEA PRIMARY KEY,
  user_id         UUID REFERENCES users(id),  -- NULL при passwordless
  expires_at      TIMESTAMPTZ NOT NULL
);

Верификация на сервере

Вручную реализовывать верификацию WebAuthn не нужно — это сложно и чревато ошибками. Используйте готовые библиотеки:

Пример верификации (Ruby + webauthn-ruby)

# Регистрация — генерация опций
def registration_options
  options = WebAuthn::Credential.options_for_create(
    user: {
      id: current_user.webauthn_id,
      name: current_user.email,
      display_name: current_user.name
    },
    authenticator_selection: {
      user_verification: "required",
      resident_key: "required"
    },
    attestation: "none"
  )

  session[:webauthn_challenge] = options.challenge
  render json: options
end

# Регистрация — верификация ответа
def registration_finish
  webauthn_credential = WebAuthn::Credential.from_create(params)

  webauthn_credential.verify(session[:webauthn_challenge])

  current_user.passkey_credentials.create!(
    external_id: Base64.strict_encode64(webauthn_credential.raw_id),
    public_key: webauthn_credential.public_key,
    sign_count: webauthn_credential.sign_count
  )
rescue WebAuthn::Error => e
  render json: { error: e.message }, status: :unprocessable_entity
end

# Аутентификация — верификация
def authentication_finish
  stored_credential = PasskeyCredential.find_by!(
    external_id: Base64.strict_encode64(params[:rawId])
  )

  webauthn_credential = WebAuthn::Credential.from_get(params)
  webauthn_credential.verify(
    session[:webauthn_challenge],
    public_key: stored_credential.public_key,
    sign_count: stored_credential.sign_count
  )

  stored_credential.update!(sign_count: webauthn_credential.sign_count)
  sign_in(stored_credential.user)
rescue WebAuthn::Error => e
  render json: { error: e.message }, status: :unauthorized
end

Что проверяет библиотека при верификации

Совместимость

Браузеры и платформы

Платформа Браузер / Среда Поддержка Хранилище ключей
macOS 13+ Safari, Chrome, Firefox, Edge Полная iCloud Keychain
iOS / iPadOS 16+ Safari, Chrome, Firefox (через Safari WebKit) Полная iCloud Keychain
Android 9+ Chrome, Firefox, Edge Полная Google Password Manager
Windows 10/11 Chrome, Firefox, Edge Полная Windows Hello (локально)
Linux Chrome, Firefox Частичная Роуминговые ключи (USB), нет платформенного

Ограничения

Cross-device authentication (Hybrid transport)

WebAuthn поддерживает аутентификацию через другое устройство по QR-коду. Сценарий: пользователь заходит с Windows-ноутбука, сканирует QR телефоном с Android, подтверждает биометрией на телефоне. Связь установлена по Bluetooth (для proximity check) + облачный relay. Никакие секреты не передаются по Bluetooth — только зашифрованный туннель.

Windows (Chrome)                  Android (Chrome)
      |                                  |
      |  Показать QR-код                 |
      |--------------------------------->| (сканировать QR)
      |                                  |
      |<-- Bluetooth proximity check --->|
      |                                  |
      |                                  | (биометрия)
      |                                  |
      |<-- Подписанный assertion --------|
      |    (через зашифрованный          |
      |     облачный канал)              |
      |                                  |
      | Верификация подписи              |
      | Сессия создана                   |

Passkey vs другие методы

Метод Безопасность UX Фишинг-устойчивость Сложность внедрения
Passkey Высокая Высокий Полная Средняя
Пароль Низкая Низкий Нет Простая
Пароль + TOTP (Google Authenticator) Средняя Низкий Частичная Средняя
SMS OTP Низкая Средний Нет Средняя
Magic Link (email) Средняя Средний Частичная Простая
OAuth (Google/GitHub Sign-In) Средняя Высокий Частичная Простая
Hardware key (YubiKey) Максимальная Средний Полная Высокая

Почему Passkey лучше TOTP

TOTP (Time-based One-Time Password, коды из Google Authenticator) — значительно лучше SMS, но уязвим к real-time фишингу. Атакующий создаёт поддельный сайт, пользователь вводит код, атакующий мгновенно использует его на настоящем сайте. Код действителен 30 секунд — достаточно для автоматизированной атаки.

Passkey защищён от этого на уровне протокола: подпись привязана к origin (домену) и challenge. Подпись для evil.com не принимается на example.com.

Почему Passkey лучше OAuth

OAuth (Sign in with Google/GitHub) удобен, но создаёт зависимость от провайдера. Если Google заблокирует аккаунт или закроет OAuth API для вашего приложения — пользователи потеряют доступ. Passkey хранится на устройстве пользователя и не зависит от третьих сторон.

Практическая рекомендация: на старте проще всего реализовать OAuth + Magic Link. Passkey стоит добавлять как дополнительный, более удобный метод для существующих пользователей — не заменяя другие методы сразу, а давая выбор.

Резюме

Ключевые принципы Passkey:

Индустриальный консенсус: Apple, Google и Microsoft подписали совместное соглашение об ускорении перехода на passkey в 2022 году. Крупные платформы уже перешли или переходят: GitHub, Google, Apple ID, PayPal, Best Buy, eBay и многие другие принимают passkey. Это не экспериментальная технология — это текущий стандарт индустрии.
Не забудьте про fallback: при внедрении passkey всегда оставляйте резервный способ входа. Пользователь может потерять устройство, не иметь доступа к iCloud/Google Account, или использовать устройство без поддержки биометрии. Email magic link или recovery codes — обязательные компоненты production-системы.