Passkey в ASP.NET Core identity
|
Пароли мертвы. Нет, серьезно - я повторяю это уже лет пять, но теперь впервые за это время чувствую, что это не просто красивые слова. В .NET 10 команда Microsoft внедрила поддержку Passkey прямо в ASP.NET Core Identity, и это реально меняет правила игры. Больше никаких "123456" в продакшене, никаких слезных звонков от пользователей "я забыл пароль к корпоративному порталу за выходные". Помню, года три назад внедрял двухфакторку в одном банковском проекте. Клиенты возмущались, бизнес требовал удобства, безопасники грозили аудитом. Классический треугольник противоречий. А Passkey - это как раз тот случай, когда безопасность и удобство наконец перестали быть антонимами. Технически Passkey - это реализация стандартов WebAuthn и FIDO2, позволяющая пользователям входить в приложения через биометрию или PIN-код устройства без хранения каких-либо секретов на сервере. Звучит просто, но за этой простотой стоит криптографическая инфраструктура, которая делает фишинг практически невозможным. Закрытый ключ вообще не покидает устройство пользователя - сервер получает только подпись, которую невозможно использовать повторно. В ASP.NET Core Identity версии 10 эта технология интегрирована на уровне фреймворка. Не нужно подключать сторонние библиотеки, не нужно писать километры кода для взаимодействия с браузерным API. Всё уже есть: от регистрации ключей до верификации подписей. Правда, есть нюансы - Microsoft реализовала базовый функционал, достаточный для большинства сценариев, но если нужна полноценная поддержка аттестации или более сложные механизмы, придется доворачивать руками или интегрировать сторонние решения. И вот что интересно: пароли в текущей реализации шаблонов всё равно остались обязательными. Это как купить Tesla, но оставить канистру бензина в багажнике на всякий случай. Что такое Passkey и почему это важно для современной разработкиPasskey - это по сути пара криптографических ключей, привязанных к конкретному домену и устройству пользователя. Закрытый ключ остается в защищенном хранилище устройства (TPM, Secure Enclave или что-то аналогичное), а открытый отправляется на сервер при регистрации. Когда пользователь пытается войти, сервер отправляет вызов (challenge), устройство подписывает его закрытым ключом, и сервер проверяет подпись открытым. Математика такая, что подделать подпись без доступа к закрытому ключу невозможно даже теоретически. Звучит сложно, но с точки зрения пользователя всё выглядит примерно так: приложил палец к сканеру, посмотрел в камеру или ввел PIN - готово, вошел. Никаких "введите пароль", "подтвердите по SMS", "введите код из приложения". Один шаг вместо трех-четырех. Почему это важно сейчас, а не пять лет назад? Потому что инфраструктура наконец дозрела. Раньше у половины пользователей не было устройств с биометрией, браузеры поддерживали WebAuthn криво, а менеджеры паролей вообще не понимали, что с этим делать. Теперь все изменилось - даже мой старенький ноутбук с Windows Hello справляется, а 1Password и Bitwarden научились синхронизировать Passkey между устройствами. Но есть и обратная сторона. Помню историю, когда знакомый потерял доступ к единственному Passkey после сброса телефона. Резервной копии не было, а приложение не предусматривало восстановление через другие методы. Провисел неделю без доступа к рабочим инструментам, пока админы вручную не восстановили ему аккаунт. Это критическая проблема UX, которую разработчики часто не учитывают. С точки зрения безопасности Passkey решает сразу несколько фундаментальных проблем. Во-первых, фишинг становится бесполезным - даже если пользователь перейдет на поддельный сайт, Passkey просто не сработает, потому что привязан к конкретному домену. Во-вторых, утечки баз данных перестают быть катастрофой - на сервере хранятся только открытые ключи, которые бесполезны без закрытых. В-третьих, атаки перебором становятся невозможны физически - невозможно перебрать 256-битный ключ даже на квантовом компьютере за обозримое время. Для разработчика это означает меньше головной боли с хешированием паролей, соляными рандомами, защитой от брутфорса и прочими радостями. Логика аутентификации упрощается до "получил challenge - проверил подпись - выдал токен". Конечно, дьявол в деталях - нужно правильно генерировать challenge, корректно валидировать ответы, учитывать таймауты. Но базовая архитектура гораздо прозрачнее, чем классическая парольная. И еще момент, который многие упускают: Passkey работает оффлайн. То есть устройство может подписать challenge без доступа к интернету, что критично для некоторых сценариев - например, в enterprise-приложениях, где сотрудники работают в изолированных сегментах сети. Правда, это требует предварительной регистрации Passkey, когда связь еще была. В контексте современной разработки это сдвиг парадигмы. Раньше мы думали категориями "что пользователь знает" (пароль) плюс "что у него есть" (телефон для SMS). Passkey переворачивает схему - теперь это "что у него есть" (устройство с ключом) плюс "кто он есть" (биометрия) или "что он знает" (PIN устройства). Разница тонкая, но принципиальная. Разница между ASP.NET Core 2, ASP.NET Core MVC, ASP.NET MVC 5 и ASP.NET WEBAPI 2 Перейти с asp.net identity на core identity ASP.NET Core. Старт - что нужно знать, чтобы стать ASP.NET Core разработчиком? Какая разница между ASP .Net Core и ASP .Net Core MVC? Технология WebAuthn и FIDO2 как основа PasskeyWebAuthn - это браузерный API, спецификация W3C, которая определяет, как веб-приложения могут использовать криптографическую аутентификацию через устройства. По сути это прослойка между вашим кодом на JavaScript и аппаратными возможностями устройства. Когда вызываешь navigator.credentials.create(), браузер начинает взаимодействовать с аутентификаторами - будь то встроенный TPM, USB-ключ или даже смартфон через Bluetooth.FIDO2 - это набор стандартов от FIDO Alliance, который включает WebAuthn плюс протокол CTAP (Client to Authenticator Protocol). Если WebAuthn описывает, как браузер общается с сайтом, то CTAP определяет, как браузер общается с физическим аутентификатором. Две части одного механизма. Без CTAP WebAuthn был бы бесполезен, потому что не знал бы, как достучаться до того же YubiKey. Я года два назад пытался внедрить WebAuthn в один проект на .NET Core 3.1 - до того, как появилась нативная поддержка. Использовал библиотеку Fido2NetLib, и это был ад. Приходилось вручную парсить CBOR-структуры, валидировать аттестационные сертификаты, разбираться с тонкостями разных типов аутентификаторов. Половину времени уходило на отладку проблем с Firefox, которий по-своему интерпретировал спецификацию. Криптографически всё строится на асимметричном шифровании. При регистрации аутентификатор генерирует пару ключей - закрытый и открытый. Открытый ключ отправляется на сервер вместе с метаданными: идентификатором ключа (credential ID), используемым алгоритмом (обычно ES256 - это ECDSA с кривой P-256), счетчиком использований и опционально аттестацией. Аттестация - это отдельная песня. Это криптографическое доказательство того, что ключ был создан именно определенным типом аутентификатора. Например, если используется YubiKey, он может подписать открытый ключ своим собственным сертификатом, подтверждая, что это действительно YubiKey, а не эмулятор. Проблема в том, что аттестация работает не везде - многие браузеры отправляют "none" attestation по умолчанию из соображений приватности. Если пользователь регистрирует Passkey, браузер не хочет палить, какое именно устройство используется. Challenge-response - сердце всего механизма. Сервер генерирует случайную строку байтов (обычно 32-64 байта), отправляет клиенту. Клиент подписывает эту строку закрытым ключом вместе с другими данными: origin сайта, счетчиком подписей, флагами присутствия пользователя. Получается структура, называемая authenticatorData, которая содержит хеш relying party ID, флаги (user present, user verified), счетчик и расширения. Вот тут начинается магия криптографии. Подпись создается по формуле: Где AuthenticatorData - бинарные данные от аутентификатора, ClientDataHash - SHA-256 хеш JSON-структуры с challenge и origin. Двойное хеширование гарантирует, что даже если кто-то перехватит подпись, использовать её повторно невозможно - challenge будет другой. На сервере происходит обратная операция. Берется сохраненный открытый ключ, проверяется подпись. Если подпись валидна, проверяется origin (должен совпадать с доменом), счетчик (должен увеличиваться с каждым использованием), флаги присутствия. Только если всё сходится, аутентификация считается успешной. Интересная деталь: счетчик подписей. Каждый раз, когда аутентификатор создает подпись, он инкрементирует внутренний счетчик. Сервер должен проверять, что новый счетчик больше предыдущего. Это защита от клонирования ключей - если вдруг кто-то скопирует ключ (теоретически это крайне сложно, но всякое бывает), то счетчики разойдутся, и сервер это заметит. Алгоритмы подписи тоже важны. WebAuthn поддерживает несколько: ES256 (ECDSA с SHA-256), RS256 (RSA с SHA-256), EdDSA. В реальности чаще всего используется ES256, потому что он быстрый и создает компактные подписи. RSA медленнее и подписи больше, но некоторые старые аутентификаторы умеют только его. EdDSA - самый новый и теоретически самый безопасный, но поддержка пока неполная. Еще один нюанс - resident keys против non-resident. Resident key (или discoverable credential) хранит информацию о пользователе прямо в аутентификаторе. То есть можно войти вообще без ввода имени пользователя - аутентификатор сам предоставит список доступных ключей. Non-resident key требует, чтобы сервер сначала сказал credential ID, и только потом аутентификатор может его использовать. В ASP.NET Core Identity по умолчанию используются resident keys для лучшего UX. Практический момент: timeout. WebAuthn операции должны завершиться за определенное время, обычно 60 секунд. Если пользователь слишком долго возится с биометрией или просто отошел от компьютера, операция прерывается. Это нужно обрабатывать на клиенте - показывать понятное сообщение, давать возможность повторить попытку. Я видел приложения, которые просто выкидывали 500 ошибку в таких случаях, и пользователи не понимали, что произошло. Внедрение Passkey в ASP.NET Core Identity версии 10Microsoft проделала неплохую работу, интегрировав Passkey прямо в ядро Identity. Раньше приходилось подключать Fido2NetLib, разбираться с десятками классов и методов, тестировать каждый кейс отдельно. Теперь основной функционал работает из коробки, хотя с оговорками - но об этом позже. Начну с того, что поддержка Passkey появилась в .NET 10 preview 6, и это важно понимать. Код еще менялся от превью к превью, и к финальному релизу может измениться еще. Я тестировал на preview 7, и там уже успели переименовать несколько ключевых методов. ConfigurePasskeyCreationOptionsAsync превратился в MakePasskeyCreationOptionsAsync - мелочь, но ломает код, если обновляешься.Архитектурно всё строится вокруг расширений SignInManager<TUser>. Добавили два основных метода: один для создания опций регистрации Passkey, другой для опций аутентификации. Плюс методы для верификации - PerformPasskeyAttestationAsync и собственно сам логин через Passkey. API получился довольно лаконичным, хотя местами чувствуется, что команда пыталась сохранить обратную совместимость с существующим Identity.
MakePasskeyCreationOptionsAsync под капотом делает несколько вещей. Генерирует случайный challenge - обычно 32 байта из криптографически стойкого генератора. Собирает информацию о поддерживаемых алгоритмах - по умолчанию это ES256, RS256 и куча других, которые мало кто использует. Формирует список уже зарегистрированных credentials для исключения дубликатов. И самое важное - сохраняет эти опции в зашифрованном authentication cookie, чтобы потом проверить, что ответ от клиента соответствует тому, что мы отправили.JSON, который улетает клиенту, выглядит примерно так:
attestation: "none". Microsoft по умолчанию не запрашивает аттестацию устройства. Это сознательное решение - аттестация может раскрывать информацию о том, какой именно аутентификатор используется, что некоторые считают нарушением приватности. Если нужна аттестация для корпоративных политик, придется использовать extensibility points и подключать стороннюю библиотеку для валидации сертификатов.После того как браузер создал Passkey и вернул credentials, нужно их проверить и сохранить. Тут в игру вступает PerformPasskeyAttestationAsync:
Passkey с извлеченным открытым ключом и метаданными.Хранение в базе данных реализовано через новую таблицу AspNetUserPasskeys. В EF Core миграция выглядит минималистично:
Data? Это JSON с открытым ключом, алгоритмом, счетчиком и прочими техническими деталями. В preview 6 было больше колонок - хранили всё раздельно - но команда упростила схему. Мне такое решение кажется спорным. С одной стороны, проще миграции. С другой - нельзя сделать индекс по алгоритму или счетчику, если вдруг понадобится выборка. Cascade delete настроен правильно - удаляешь пользователя, автоматом удаляются все его Passkey. Но нет никакой истории изменений. Если пользователь удалил Passkey случайно, восстановить нельзя. В enterprise системах я бы добавил soft delete с полем IsDeleted и таймстампом. Но в базовом шаблоне такого нет.Интеграция с существующей моделью IdentityUser происходит через extension методы. Сама модель не меняется - нет новых свойств или навигационных коллекций. Passkey живут отдельно, связываясь только через UserId. Это хорошо с точки зрения обратной совместимости, но создает разрыв - нельзя просто сделать user.Passkeys.ToList(), приходится лезть через UserManager.
Data, не влияет на криптографию никак.Конфигурация параметров Passkey происходит через IdentityOptions, хотя настроек там минимум. Можно задать timeout для операций - по умолчанию 60 секунд, что разумно. Можно указать список разрешенных алгоритмов подписи, хотя зачем ограничивать ES256 и RS256, если они оба безопасны, не совсем понятно. Серверный endpoint для генерации challenge задается при регистрации маршрутов.
Обработка ошибок - слабое место текущей реализации. Методы возвращают IdentityResult или специализированные результаты типа PasskeyAttestationResult, но сообщения об ошибках часто неинформативны. "Attestation verification failed" - окей, но почему? Challenge не совпал? Origin неправильный? Подпись битая? Приходится лезть в исходники Identity, чтобы понять, что именно пошло не так.Я делал обертку над стандартными методами, которая логирует детальную информацию при ошибках:
RetrievePasskeyCreationOptionsAsync возвращает null, и приходится генерировать опции заново.Работа с несколькими Passkey одного пользователя поддерживается нормально. Можно зарегистрировать сколько угодно ключей - для ноутбука, телефона, планшета, USB-ключа. При логине браузер покажет список доступных credentials, пользователь выберет нужный. Но тут есть нюанс с resident keys. Если браузер поддерживает условный медиацию (conditional mediation), он может показать сохраненные Passkey прямо в поле ввода логина. Выглядит красиво, но работает не везде - Safari, например, реализовал это только в последних версиях. Проблема в том, что Identity не умеет ранжировать Passkey по приоритету. Последний использованный, самый свежий, основной - нет такого понятия. Все ключи равны. Пользователю приходится каждый раз выбирать из списка, даже если он всегда использует один и тот же. Я видел решения, где хранили timestamp последнего использования в JSON и сортировали по нему, но это уже кастомизация. Безопасность хранения - открытые ключи в базе не шифруются. Зачем? Они публичные. Но вот метаданные - имена ключей, счетчики подписей - тоже лежат открыто. Счетчик - критичная информация для детектирования клонирования. Если злоумышленник получит доступ к базе и увидит текущее значение счетчика, он может попытаться подделать его при атаке. Хотя без закрытого ключа это бесполезно, но всё равно - defense in depth никто не отменял. Ограничения текущей версии довольно серьезные. Нет поддержки кросс-платформенных Passkey из коробки. То есть синхронизация через облако типа iCloud Keychain или 1Password работает на уровне браузера, но Identity про это не знает. Нет механизмов для резервного копирования или экспорта ключей. Нет поддержки attestation validation - если нужно проверять, что используется именно сертифицированный FIDO2 аутентификатор, придется писать свой код. Апгрейд схемы базы данных при переходе с .NET 9 требует миграции. Новая таблица AspNetUserPasskeys создается автоматически, но если у тебя уже была кастомная реализация Passkey или ты использовал Fido2NetLib - конфликты гарантированы. Придется либо переименовывать старые таблицы, либо мигрировать данные, либо держать два механизма параллельно на время перехода. Тестирование Passkey локально - отдельная песня. Браузеры требуют HTTPS или localhost. Если разрабатываешь на виртуалке или в Docker контейнере, могут быть проблемы с доступом к аутентификаторам хост-системы. Эмуляторы WebAuthn в Chrome DevTools помогают, но не покрывают все сценарии - особенно с биометрией и cross-device authentication.Практическая реализация регистрации через PasskeyКлиентская часть начинается с вызова серверного endpoint для получения опций регистрации. Тут первый момент, который многие упускают - нужен authenticated контекст. Пользователь должен быть залогинен через обычный пароль, прежде чем регистрировать Passkey. Логика проста: мы не можем создать Passkey для несуществующего или неподтвержденного пользователя. В Blazor шаблоне это реализовано через форму с CSRF токеном:
passkey-submit:
parseCreationOptionsFromJSON появился в спецификации относительно недавно и избавляет от ручного декодирования base64url строк. Раньше приходилось писать функции типа base64urlToBuffer, конвертировать challenge и user.id из строк в ArrayBuffer. Сейчас браузер делает это сам. Но вот засада - Safari начал поддерживать этот метод только с версии 16, а в корпоративной среде я встречал компы на 15.6. Пришлось делать фоллбэк:
navigator.credentials.create() - момент истины. Тут браузер показывает нативный диалог, и дальнейшие действия зависят от пользователя. Он может выбрать создание ключа в аппаратном токене, в биометрическом сканере Windows Hello, в профиле Chrome, или вообще отменить операцию. Timeout по умолчанию 60 секунд, но можно переопределить через параметр timeout в опциях. Я обычно ставлю 120 - бывает пользователь отвлекается или долго возится с USB-ключом.Важный момент - метод асинхронный и может зависнуть надолго. Нужен механизм отмены через AbortController:
PublicKeyCredential содержит ArrayBuffer'ы, которые нельзя напрямую сериализовать в JSON. Опять же, есть современный метод credential.toJSON(), который делает всю грязную работу, но с фоллбэком на старые браузеры:
PerformPasskeyAttestationAsync. Метод проверяет кучу вещей: что challenge совпадает с тем, что мы генерировали, что origin правильный, что формат attestationObject валидный CBOR, что подпись корректна. Любая ошибка приводит к отказу.Обработка ошибок критична. Пользователи не разбираются в терминологии WebAuthn, поэтому сообщения типа "InvalidStateError" нужно переводить в понятные фразы:
Реальная засада, с которой столкнулся - резидентные ключи заполняют память аутентификатора. USB-токены типа YubiKey 5 могут хранить максимум 25 резидентных ключей. Если превысить лимит, старые начинают перезаписываться без предупреждения. Пользователь зарегистрировал Passkey, всё работало, а через месяц - облом, ключ не находится. Оказалось, он накликал 30 ключей в разных сервисах, и первые вылетели. Решения нет, кроме как предупреждать пользователей и рекомендовать использовать менеджеры паролей с поддержкой синхронизации Passkey. Тестирование локально упрощается через Virtual Authenticators в Chrome DevTools. Открываешь панель WebAuthn, создаешь виртуальный аутентификатор с нужными параметрами - CTAP2, resident keys, user verification. Можно симулировать разные устройства - Windows Hello, Touch ID, USB-токены. Очень удобно для отладки, особенно когда реального железа под рукой нет. Еще один момент, который часто упускают - параметр authenticatorSelection в опциях регистрации. Он контролирует, какой тип аутентификаторов может использоваться. По умолчанию Microsoft не ограничивает, что позволяет юзерам регистрировать и платформенные ключи (Windows Hello, Touch ID), и cross-platform устройства (YubiKey, телефоны). Но иногда нужна жесткость - например, в корпоративной среде разрешены только сертифицированные USB-токены:
AuthenticatorAttachment.Platform, который разрешает только встроенные аутентификаторы. Половина юзербазы не могла зарегистрировать Passkey, пока не откатили настройку.UserVerification - это требование к проверке личности пользователя во время операции. Три варианта: Required - обязательна биометрия или PIN, Preferred - желательна но не обязательна, Discouraged - не нужна. Последний вариант звучит странно, но имеет смысл для низкорисковых сценариев - например, вход в форум или читалку новостей. Зачем заставлять прикладывать палец ради просмотра мемов?Практически я всегда ставлю Required для production систем. Безопасность важнее удобства, особенно когда речь о финансах или персональных данных. Но это создает проблему - старые USB-ключи без биометрии и кнопки требуют только physical presence (нажал кнопку), но не могут сделать user verification. Такие ключи просто откажутся создавать credential с Required. Приходится либо разрешать Preferred, либо заставлять пользователей апгрейдить железо.Параметр excludeCredentials заслуживает отдельного внимания. Это массив уже зарегистрированных credential ID для текущего пользователя. Передаешь его в опциях создания, и браузер откажется регистрировать тот же самый ключ повторно. Без этого пользователь может случайно зарегистрировать один и тот же аутентификатор несколько раз, захламив список.
AttestationObject содержит эту информацию, но парсить CBOR самостоятельно - удовольствие сомнительное. В текущей реализации Identity эти данные просто игнорируются.CBOR (Concise Binary Object Representation) - это бинарный формат сериализации, что-то среднее между JSON и Protocol Buffers. WebAuthn использует его для передачи attestationObject, потому что компактнее и быстрее парсится чем JSON. Структура выглядит примерно так:
authData закопано всё самое интересное: relying party ID hash, флаги, счетчик подписей, credential ID, открытый ключ в формате COSE. Парсинг этого вручную - боль. К счастью, PerformPasskeyAttestationAsync делает это за нас, но понимание структуры помогает при отладке. Когда видишь ошибку "Invalid authData length", знаешь что искать проблему в размере credential ID или publicKey.Синхронная vs асинхронная регистрация - еще один нюанс. В примерах выше клиент ждет ответа от сервера синхронно - форма блокируется, пока не придет редирект. Это простое решение, но не самое юзер-френдли. Лучше делать через AJAX с показом прогресса:
app.example.com, а API на api.example.com, браузер не отправит authentication cookies без правильных заголовков. Нужен Access-Control-Allow-Credentials: true на сервере и credentials: 'include' в fetch запросах. Плюс домены должны совпадать по SameSite политике. Я потратил полдня отлаживая почему Passkey не регистрируется в staging окружении - оказалось, SameSite=Strict блокировал cookies при redirect'ах между поддоменами.Особенность Blazor Server - там всё работает через SignalR соединение. Стандартные формы с POST запросами превращаются в WebSocket сообщения. Passkey создание всё равно идет через обычный HTTP, потому что WebAuthn API требует user gesture - нажатие кнопки. Нельзя инициировать создание credential программно, без действия пользователя. Это защита от скрытой регистрации ключей без ведома юзера. Именование credential ID заслуживает внимания. Это случайный identifier, который генерирует аутентификатор, обычно 16-32 байта. Использовать его напрямую как primary key в базе можно, но неудобно - придется везде таскать байтовый массив. Я видел решения где делали GUID из первых 16 байт credential ID, но это создает риск коллизий. Безопаснее хранить сам ID как есть в бинарном виде и делать уникальный индекс. Проблема с множественными одновременными регистрациями - race condition. Пользователь нажал "Добавить Passkey", пошел процесс. Не дождавшись, нажал еще раз. Два параллельных запроса на генерацию опций, два challenge в cookies, браузер создает credential по одному из них. Какой из двух challenge валидный? Неизвестно. Решение - блокировать UI на время операции или проверять на сервере что нет активной pending регистрации для этого пользователя. Время жизни challenge - критичный параметр безопасности. Слишком короткое - пользователи не успевают. Слишком длинное - окно для replay атак. Microsoft ставит 5 минут по умолчанию, что кажется разумным. Но в корпоративной среде с медленными сетями и пользователями которые долго думают, это может быть мало. Я делал настраиваемый timeout через конфигурацию, обычно 10-15 минут для enterprise, 3-5 для публичных сервисов. Аутентификация пользователей с использованием PasskeyВход через Passkey устроен проще, чем регистрация, но дьявол в деталях. Основная разница - вместо создания новой пары ключей мы используем существующую. Сервер генерирует challenge, отправляет клиенту, тот подписывает его закрытым ключом, подпись возвращается обратно и проверяется. Звучит элементарно, но каждый шаг полон нюансов. Клиентская часть начинается с запроса опций аутентификации. В отличие от регистрации, тут не нужен authenticated контекст - пользователь еще не залогинен. Но есть опциональный параметр username, который улучшает UX. Если пользователь ввел email в форму, сервер может вернуть только те credential ID, которые привязаны к этому аккаунту. Браузер покажет релевантные Passkey, а не все подряд.
mediation: 'conditional' - это новая фича, называемая conditional UI. Если браузер поддерживает её, сохраненные Passkey появляются прямо в автозаполнении поля username. Пользователь видит список своих аккаунтов с иконками, выбирает нужный, подтверждает биометрией - готово, вошел. Без всякого ручного выбора credential. Работает в Chrome 108+, Safari 16+, Firefox 119+. Старые браузеры просто игнорируют этот параметр.Видел кейс где conditional UI создал путаницу. Дизайнеры сделали кастомное поле ввода email со стилизацией, которая перекрывала нативный автозаполнение браузера. Пользователи не видели Passkey в списке и думали что функция не работает. Пришлось переделывать на стандартный input с минимальными стилями. На сервере генерация опций для входа проще чем для регистрации. Не нужна информация о пользователе в явном виде, только challenge и опциональный список allowedCredentials:
ConfigurePasskeyRequestOptionsAsync добавит в опции список его credential ID. Браузер покажет только эти ключи. Если пользователь не передан, браузер покажет все Passkey которые работают с этим доменом. Это называется username-less flow, удобно для быстрого входа, но может запутать если у юзера несколько аккаунтов на одном сайте.JSON ответа минималистичнее чем при регистрации:
allowCredentials - если он пустой, браузер предложит все доступные ключи. Если заполнен, только указанные. В корпоративных системах где у сотрудников может быть по 5-10 ключей от разных сервисов, фильтрация критична. Иначе пользователь видит огромный список и не понимает какой выбрать.UserVerification тут тоже имеет значение. Preferred означает что браузер попытается сделать user verification если возможно, но не обязательно. Если установить Required, старые USB-ключи без биометрии откажутся работать. Приходится балансировать между безопасностью и совместимостью. Для финансовых приложений я всегда ставлю Required, для остальных - Preferred.После того как браузер получил подпись от аутентификатора, нужно отправить её на сервер. Структура credential для аутентификации отличается от регистрации - вместо attestationObject приходит authenticatorData и signature отдельно:
Серверная валидация - самая важная часть. Тут проверяется подпись, счетчик, флаги присутствия пользователя:
Самое интересное - проверка счетчика подписей. Каждый раз когда аутентификатор создает подпись, он инкрементирует внутренний счетчик и включает его в authenticatorData. Сервер должен проверить что новый счетчик больше предыдущего сохраненного значения. Если счетчик меньше или равен - это признак клонированного ключа или replay атаки:
Но есть нюанс с производительностью. Если у пользователя 10 ключей и мы передаем все их ID в allowCredentials, объем JSON вырастает. Каждый credential ID - это 16-32 байта в base64url, плюс обертка. При большом количестве пользователей и ключей это начинает давить на трафик. Оптимизация - кешировать список credential ID на клиенте в localStorage и обновлять только при изменениях. Правда это создает риск рассинхронизации, если пользователь удалил ключ с другого устройства.Обработка ошибок при логине критична для UX. Пользователи не понимают технических терминов, нужны человеческие сообщения:
Timeout при аутентификации - проблема недооцененная. 60 секунд кажется достаточным, но в реальности пользователи отвлекаются. Телефонный звонок, коллега подошел с вопросом, ребенок что-то разлил. Возвращается к экрану - операция протухла, нужно начинать сначала. Я ставлю 120 секунд для публичных сервисов, 180 для корпоративных где люди часто многозадачны. Правда это расширяет окно для потенциальных атак, поэтому challenge должен быть криптографически стойким - минимум 32 байта случайности из CSPRNG. Session management после успешного входа через Passkey ничем не отличается от обычного. SignInManager создает authentication cookie, записывает claims, устанавливает expiration. Но тут вопрос - как долго держать сессию? При парольном входе логично делать короткую сессию и просить реаутентификацию. С Passkey реаутентификация занимает секунду, так что можно смело ставить сессии покороче, не боясь раздражить пользователей. Видел проекты где сессия живет 15 минут - после этого просто всплывает нативный диалог биометрии, подтвердил и работаешь дальше.Remember me функциональность становится спорной. Зачем checkbox "Запомнить меня" если вход через Passkey и так мгновенный? Традиционно remember me увеличивает время жизни cookie с нескольких часов до недель. Но при использовании биометрии нет смысла держать долгоживущую сессию - быстрее переавторизоваться чем рисковать утечкой сессионного токена. Я обычно вообще убираю этот checkbox из UI при Passkey логине. Кросс-браузерная совместимость - больная тема. Chrome работает отлично начиная с версии 67. Firefox поддерживает с версии 60, но с косяками - conditional UI появился только в 119. Safari на macOS хорош с версии 13, но на iOS были проблемы до версии 14. Edge копирует Chrome движок, так что в целом норм. Проблема в том что в enterprise окружениях встречаются древние версии браузеров. IE11, который еще где-то используют, вообще не понимает WebAuthn. Приходится делать feature detection и скрывать Passkey кнопку если API недоступен:
Корпоративные прокси умеют ломать WebAuthn очень креативно. TLS inspection прокси расшифровывают трафик и заново шифруют своим сертификатом. Браузер видит что origin изменился в процессе, и отказывается работать с Passkey - защита от MITM. Решение - добавить исключение в прокси для вашего домена или использовать certificate pinning. Но пинить сертификаты в enterprise среде это отдельный квест согласований с инфобезом. VPN тоже может создать проблемы, особенно split-tunnel конфигурации. Видел кейс где пользователи дома логинились через Passkey нормально, а из офиса через VPN - отказ. Оказалось VPN роутил только определенные подсети через туннель, а DNS запросы шли напрямую. В результате браузер считал что домен другой и rpIdHash не совпадал. Пришлось переделывать VPN конфигурацию чтобы весь трафик шел через туннель. Производительность проверки подписи при большой нагрузке заслуживает внимания. ECDSA верификация на ES256 довольно быстрая - современный сервер может проверять десятки тысяч подписей в секунду. Но если у вас миллионы одновременных логинов (представь что Black Friday на крупном e-commerce), CPU может стать узким местом. Я тестировал на 8-core сервере - около 40 тысяч верификаций в секунду. Звучит много, но при пиковых нагрузках может не хватить. Решение - кеширование результатов проверки с коротким TTL или offload на аппаратные HSM модули для крупных систем. Database queries при каждом логине тоже нагружают. Нужно достать публичный ключ по credential ID, проверить что он принадлежит правильному пользователю, обновить счетчик подписей, записать timestamp последнего использования. Четыре запроса минимум. При высоком RPS стоит кешировать публичные ключи в Redis - они меняются редко, только при перерегистрации. Счетчик обновлять асинхронно через очередь сообщений, не блокируя ответ пользователю. Главное проверку подписи делать синхронно, остальное можно отложить. Логирование критически важно для отладки и аудита. Каждый вход через Passkey должен записываться: timestamp, user ID, credential ID, IP адрес, user agent, success/failure, причина отказа если был. Это помогает детектировать подозрительную активность - например, если один Passkey используется с разных IP адресов одновременно, это признак компрометации или клонирования. Правда нужно учитывать легитимные сценарии типа балансировки нагрузки где исходящий IP может прыгать:
Graceful degradation при проблемах на стороне браузера важна. Если WebAuthn API выбросил исключение, нельзя просто показать 500 ошибку. Нужно логировать детали для отладки, но пользователю предложить альтернативу - войти через пароль, или попробовать другой браузер. Видел системы которые при первой ошибке Passkey автоматически редиректили на форму с паролем и SMS кодом, без объяснений. Пользователи думали что Passkey у них сломался навсегда и больше не пробовали. Архитектура безопасности и внутренние механизмыКриптографическая архитектура Passkey строится на математике, которую не взломать перебором даже если вселенная просуществует еще миллиард лет. Основа - асимметричное шифрование на эллиптических кривых, конкретно алгоритм ECDSA (Elliptic Curve Digital Signature Algorithm). Выбор не случаен - эллиптические кривые дают такую же стойкость как RSA, но с ключами в четыре раза короче. 256-битный ключ на кривой P-256 эквивалентен 3072-битному RSA ключу по сложности взлома. Математика кривых элегантна. Определяется уравнение вида Когда создается подпись, используется формула: Где Важнейшая деталь - случайность Привязка к домену - гениальное решение проблемы фишинга. Когда браузер вызывает WebAuthn API, он автоматически берет текущий origin сайта и передает его в clientData. Аутентификатор проверяет что rpId в запросе соответствует origin'у, и включает хеш rpId в authenticatorData. Сервер при верификации пересчитывает SHA-256 от своего домена и сравнивает с rpIdHash из authenticatorData. Если не совпадает - отказ. Красота в том что фишинговый сайт физически не может подделать origin. Даже если злоумышленник создаст точную копию вашего сайта на домене evil-site.com и как-то заставит пользователя попытаться войти через Passkey, браузер передаст в clientData origin evil-site.com. AuthenticatorData будет содержать хеш этого домена. А ваш сервер ожидает хеш вашего реального домена. Несовпадение - и атака провалена. Я тестировал этот механизм в лабораторных условиях - поднял локальный сайт, зарегистрировал Passkey, потом попытался использовать тот же credential на другом домене. Браузер даже не предложил использовать ключ - он привязан к оригинальному rpId и не будет работать нигде кроме него. Это фундаментальное отличие от паролей, которые пользователь может ввести где угодно.Subdomain handling тоже продуман. Можно зарегистрировать Passkey на example.com и он будет работать на app.example.com и api.example.com, если явно указать rpId как example.com при регистрации. Но обратное не работает - зарегистрированный на поддомене ключ не будет валиден на родительском домене. Асимметрия защищает от повышения привилегий через компрометацию поддомена.Защита от replay атак заложена через challenge. Каждый раз генерируется новый случайный вызов, аутентификатор подписывает его вместе с другими данными, сервер проверяет что полученный challenge совпадает с отправленным. Повторно использовать ту же подпись невозможно - challenge будет другой, подпись не пройдет верификацию. Даже если атакующий перехватит весь трафик, максимум что он получит - одноразовую подпись для уже использованного challenge. Временные окна challenge ограничены. Если challenge живет слишком долго, теоретически возможна атака по времени - злоумышленник перехватывает challenge, ждет пока жертва войдет с другого устройства, потом переиспользует тот же challenge. На практике это сложно, потому что challenge обычно протухает за 5-10 минут, и нужна очень точная синхронизация. Но defense in depth требует коротких таймаутов. Аттестация - самая противоречивая часть спецификации. Идея в том что аутентификатор может доказать свою подлинность, подписав открытый ключ пользователя своим собственным сертификатом. Существует несколько форматов: packed (общий формат для сертифицированых устройств), tpm (для Windows TPM), android-key (для Android KeyStore), none (без аттестации). Проблема в приватности - аттестационный сертификат уникален для устройства и может использоваться для трекинга пользователя между сайтами. Поэтому большинство браузеров по умолчанию не передают реальную аттестацию, возвращая attestation format "none". Enterprise системы могут потребовать настоящую аттестацию для compliance - например, банк хочет убедиться что пользователь использует именно FIDO2-сертифицированный YubiKey, а не самопальный эмулятор на взломанном Android. Но для обычных веб-приложений аттестация избыточна и создает проблемы с приватностью. Валидация аттестационных сертификатов - отдельный квест. Нужно проверить всю цепочку до корневого сертификата, убедиться что сертификаты не отозваны через CRL или OCSP, проверить что extended key usage разрешает аттестацию. Microsoft Identity не делает этого из коробки, оставляя extensibility point для подключения сторонних библиотек. В enterprise проектах я использовал Fido2NetLib для полной валидации - там есть готовая логика проверки сертификатов FIDO Metadata Service. Сравнение с OAuth показывает разницу подходов. OAuth - это делегирование авторизации, когда пользователь разрешает приложению действовать от его имени через токены. Passkey - прямая аутентификация пользователя через криптографическое доказательство владения закрытым ключом. OAuth токены можно украсть и переиспользовать, пока они валидны. Passkey нельзя украсть в принципе - закрытый ключ не покидает устройство. JWT токены после успешной аутентификации решают другую задачу - передачу authenticated состояния между сервисами. Passkey не заменяет JWT, а дополняет. После входа через Passkey выдается JWT access token, который клиент использует для API запросов. Разница в том что JWT можно выпустить только после успешной аутентификации, а традиционные системы делают это после проверки пароля. Passkey просто более надежный способ доказать что ты - это ты. Традиционные сессионные cookies уязвимы к CSRF, session fixation, session hijacking. Passkey частично решает эти проблемы - каждая аутентификация криптографически доказана, нельзя подменить user ID или повысить привилегии без валидного credential. Но после создания сессии безопасность снова зависит от защиты cookie - HttpOnly, Secure, SameSite флаги остаются важны. Passkey делает вход безопаснее, но не отменяет необходимость защищать session token. Hardware Security Modules добавляют еще один уровень защиты в корпоративных системах. Вместо хранения публичных ключей в обычной базе данных, они сохраняются в HSM с аппаратной защитой от извлечения. Проверка подписей происходит внутри HSM без экспорта ключей. Производительность падает - HSM медленнее обычного CPU, но безопасность растет экспоненциально. Видел финансовую систему где все Passkey операции шли через Thales Luna HSM - верификация занимала 50мс вместо обычных 2мс, но зато compliance офицеры были счастливы. Квантовая угроза нависает над всей криптографией на эллиптических кривых. Алгоритм Шора на достаточно мощном квантовом компьютере сможет вычислить закрытый ключ из открытого за полиномиальное время. Но текущие квантовые компьютеры далеки от необходимой мощности, а постквантовая криптография активно развивается. NIST уже стандартизировал несколько постквантовых алгоритмов типа CRYSTALS-Dilithium. Когда квантовая угроза станет реальной, WebAuthn обновят спецификацию для поддержки новых алгоритмов подписи. Миграция займет годы, но инфраструктура для этого уже предусмотрена через механизм согласования алгоритмов в pubKeyCredParams. Защита закрытых ключей в аутентификаторах - это территория аппаратной безопасности, где софт уже бессилен. Trusted Platform Module в Windows машинах хранит ключи в изолированном криптопроцессоре, физически отделенном от основного CPU. Извлечь ключ из TPM практически невозможно без физического вскрытия чипа и атаки через electron microscopy - процедура которая стоит сотни тысяч долларов и требует специализированной лаборатории. Для обычного злоумышленника это экономически нецелесообразно. Secure Enclave в Apple устройствах идет еще дальше. Это отдельный ARM-процессор со своей памятью и операционной системой, который работает параллельно основному чипу. Ключи генерируются внутри Enclave и никогда не покидают его периметр. Даже если кто-то получит root доступ к iOS, Secure Enclave останется недоступен - между ним и основной системой только узкий API для запросов подписи. Биометрические данные тоже обрабатываются исключительно внутри Enclave, создавая defense in depth. Но аппаратная защита не панацея. Side-channel атаки умеют извлекать секреты через анализ побочных эффектов - потребление энергии, электромагнитное излучение, время выполнения операций. Differential Power Analysis может восстановить биты ключа наблюдая за флуктуациями напряжения во время криптографических операций. Видел исследование где команда из университета Гронингена смогла извлечь ECDSA ключ из смарт-карты после записи нескольких тысяч power traces. Правда требовалось физическое подключение к карте и специальное оборудование, но концептуально возможно. Современные аутентификаторы применяют контрмеры. Добавляют случайные задержки в вычисления - timing jitter. Вставляют фиктивные операции между реальными - operation blinding. Используют constant-time алгоритмы где время выполнения не зависит от обрабатываемых данных. Это замедляет работу процентов на 10-15, зато делает side-channel атаки на порядки сложнее. Биометрическая безопасность вносит свои компромиссы. Отпечаток пальца можно подделать - есть задокументированные случаи обхода Touch ID через муляжи из желатина или силикона. Face ID надежнее благодаря 3D-сканированию, но и его обходили через маски напечатанные на 3D принтере, правда сложность такой атаки высока. PIN-код устройства проще - его невозможно скопировать незаметно, но легко подсмотреть или вытянуть через shoulder surfing. Интересный момент - биометрия не хранится на сервере вообще. Passkey использует биометрию только для разблокировки доступа к закрытому ключу на устройстве. Сервер никогда не видит ни отпечаток, ни скан лица. Это радикально отличается от биометрических баз данных в аэропортах или полицейских системах, где template хранится централизованно и может утечь. При компрометации базы Passkey злоумышленник получает только бесполезные публичные ключи. Синхронизация ключей между устройствами - технология спорная. iCloud Keychain и Google Password Manager научились синхронизировать Passkey через облако, шифруя их end-to-end. Удобно - зарегистрировал на iPhone, автоматически доступен на Mac. Но появляется централизованное хранилище, пусть и зашифрованное. Если кто-то взломает Apple ID или Google аккаунт через phishing, получит доступ ко всем синхронизированным ключам. Это возвращает часть рисков которые Passkey должны были устранить. Видел enterprise политики, которые запрещают синхронизацию Passkey через публичные облака. Требуют регистрировать отдельный ключ на каждом устройстве, что неудобно, но безопаснее. Компромисс - корпоративное MDM решение, которое управляет синхронизацией через контролируемую инфраструктуру. Проблема в том что это работает только для корпоративных устройств, личные телефоны сотрудников остаются за бортом. Backup и восстановление Passkey - проблема которую никто толком не решил. Если единственный зарегистрированный ключ на сломанном телефоне, всё - доступ потерян навсегда. Account recovery mechanisms возвращают нас к парадигме паролей или секретных вопросов, теряя преимущества Passkey. Некоторые сервисы требуют регистрировать минимум два ключа на разных устройствах, что разумно, но пользователи ленятся это делать. Revocation механизмы работают через удаление публичного ключа из базы. После этого любая попытка аутентификации с этим credential провалится - сервер просто не найдет соответствующий ключ для проверки подписи. Но это reactive approach - работает только после того как пользователь обнаружил компрометацию и явно отозвал ключ. Proactive detection сложнее - как понять что ключ скомпрометирован если атакующий использует его аккуратно? Счетчик подписей помогает частично - резкий скачок или откат счетчика сигнализирует о проблеме. Но многие аутентификаторы возвращают нулевой счетчик всегда. Geolocation tracking может детектировать подозрительные входы - если ключ использовался в Москве, а через минуту в Сан-Франциско, это физически невозможно. Правда VPN ломает эту логику, создавая false positives. Audit trails критичны для compliance. GDPR, PCI DSS, HIPAA - все требуют детального логирования аутентификационных событий. Каждое использование Passkey должно фиксироваться: timestamp с миллисекундной точностью, source IP, credential ID, success/failure, user agent. Логи должны храниться immutable - нельзя модифицировать задним числом. Обычно используют append-only storage или blockchain-подобные структуры где каждая запись криптографически связана с предыдущей. Видел финансовую систему где audit logs Passkey аутентификаций шли в отдельную базу данных на read-only replica с delayed replication. Даже если атакующий получит полный доступ к production базе, удалить или изменить логи за последние 24 часа он не сможет - они еще не реплицировались. Параноидальный подход, но для банковского сектора оправданный. Performance implications аппаратной криптографии иногда неожиданны. TPM операции могут занимать 50-100мс из-за контекстных переключений между основным CPU и криптопроцессором. Если на каждый запрос API делать подпись через TPM, latency вырастет заметно. Поэтому после успешной Passkey аутентификации обычно выдается JWT токен с expiration, который клиент использует для последующих запросов без повторного обращения к TPM. Батарея мобильных устройств тоже страдает. Биометрические сканеры и криптопроцессоры потребляют энергию. Если пользователь логинится по 50 раз в день через Face ID, это ощутимо съедает заряд. Оптимизация - держать сессии подольше, но это уже компромисс между удобством и безопасностью. Баланс найти сложно, особенно для mobile-first приложений где пользователи ожидают мгновенного отклика. SecureAuthPortal с логином через PasskeyСобрал рабочее приложение, которое демонстрирует всю цепочку от регистрации до логина через Passkey. Назвал его SecureAuthPortal - простенько, зато по делу. Это не hello-world пример на три файла, а полноценный проект который можно взять и запустить в production после минимальных доработок под свои нужды. Архитектурно решил не изобретать велосипед. Взял стандартный Blazor Server template с Individual Authentication как основу, но выпилил половину ненужного шаблонного кода и добавил нормальную структуру. Clean Architecture мне тут показалась избыточной - для демонстрационного проекта хватит разделения на слои: Presentation (Blazor компоненты), Application (бизнес-логика), Infrastructure (база данных, Identity). Структура проекта получилась следующая:
UserManager и SignInManager. Зачем? Потому что напрямую работать с Identity в компонентах - это боль при тестировании и рефакторинге. Если завтра захочу добавить дополнительную валидацию или кастомную логику, придется лезть во все компоненты. А так меняю только один сервис.
Audit logging реализовал через отдельную таблицу. Каждая операция с Passkey пишется туда с полными деталями:
dotnet run, открыть в браузере, зарегистрировать аккаунт и сразу добавить Passkey. Работает из коробки, без дополнительных настроек. Единственное требование - HTTPS или localhost, иначе WebAuthn откажется функционировать.Управление ключами реализовал через отдельный компонент PasskeyManager, который показывает список зарегистрированных credentials с возможностью переименования и удаления. Тут важный момент - нельзя просто удалять ключи без подтверждения, иначе пользователь случайно кликнет и останется без доступа.
Тестирование заслуживает отдельного внимания. Unit-тесты для сервисов пишутся легко благодаря инъекции зависимостей. Мокаю UserManager, SignInManager и проверяю что методы вызываются с правильными параметрами. Integration тесты сложнее - нужен реальный браузер для WebAuthn. Использовал Playwright с виртуальным аутентификатором:
Мониторинг настроил через Application Insights. Трекаю кастомные метрики: количество Passkey регистраций за час, success rate логинов, average latency проверки подписей. Алерты на аномалии - если error rate подскакивает выше 10% или registration rate падает в ноль, прилетает уведомление в Teams канал.
Финальный проект получился компактным - около 3000 строк кода включая тесты, но функционально полным. Можно взять, поправить пару строчек конфигурации под свое окружение и деплоить. Исходники выложил на GitHub под MIT лицензией - пусть люди учатся и используют в своих проектах. Единственное что не реализовал - cross-device authentication, потому что это требует дополнительных серверных компонентов для QR кодов и WebSocket связи между устройствами. Может быть в следующей версии. ЗаключениеPasskey в ASP.NET Core Identity - это шаг вперед, но не революция которую нам обещали. Технология работает, безопасность выше чем у паролей, UX лучше чем у SMS-кодов. Проблема в том что Microsoft реализовала базовый минимум, оставив кучу edge cases на откуп разработчикам. Нет полноценной аттестации, нет удобных механизмов бекапа, нет встроенной защиты от массовых регистраций фейковых ключей. Видел достаточно проектов чтобы сказать: Passkey сработает если внедрять постепенно и не форсировать. Пользователям нужно время привыкнуть, IT-командам - время обкатать процессы, бизнесу - время понять реальные выгоды. Aggressive push приводит к оттоку клиентов, видел это своими глазами. Что касается безопасности - да, фишинг становится бесполезным, утечки баз перестают быть катастрофой. Но появляются новые риски: потеря единственного устройства с ключом, проблемы синхронизации через облака, зависимость от поддержки браузеров. Это трейдофф, не панацея. В целом направление правильное. Пароли действительно устарели, и Passkey - один из немногих реально работающих вариантов замены. Просто не ждите что это решит все проблемы аутентификации разом. Технология молодая, экосистема формируется, стандарты эволюционируют. Через пару лет вернемся к этой теме и посмотрим что изменилось. ASP.NET MVC 4,ASP.NET MVC 4.5 и ASP.NET MVC 5 большая ли разница между ними? Asp.net core identity Очистка cookies в ASP.NET Core Identity ASP.NET Core Identity (в проекте WebApi) Identity в ASP.NET Core 2.0 Можно ли использовать ASP.Net Core Identity вместе с Angular? Использование Identity Server и ASP .Net Core 3.00 с Angular ASP.NET Core Identity - Из коробки или добавление самостоятельно Ошибка при запуске приложения ASP.Net Core Identity Где находится контроллер регистрации в стандартном шаблоне Visual Studio asp net core mvc + identity Связь таблиц один ко многим в ASP NET Core Identity Управление ролями пользователей с помощью Identity в ASP.NET CORE MVC | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||


